comparison CSP2/CSP2_env/env-d9b9114564458d9d-741b3de822f2aaca6c6caa4325c4afce/lib/python3.8/idlelib/squeezer.py @ 69:33d812a61356

planemo upload commit 2e9511a184a1ca667c7be0c6321a36dc4e3d116d
author jpayne
date Tue, 18 Mar 2025 17:55:14 -0400
parents
children
comparison
equal deleted inserted replaced
67:0e9998148a16 69:33d812a61356
1 """An IDLE extension to avoid having very long texts printed in the shell.
2
3 A common problem in IDLE's interactive shell is printing of large amounts of
4 text into the shell. This makes looking at the previous history difficult.
5 Worse, this can cause IDLE to become very slow, even to the point of being
6 completely unusable.
7
8 This extension will automatically replace long texts with a small button.
9 Double-clicking this button will remove it and insert the original text instead.
10 Middle-clicking will copy the text to the clipboard. Right-clicking will open
11 the text in a separate viewing window.
12
13 Additionally, any output can be manually "squeezed" by the user. This includes
14 output written to the standard error stream ("stderr"), such as exception
15 messages and their tracebacks.
16 """
17 import re
18
19 import tkinter as tk
20 import tkinter.messagebox as tkMessageBox
21
22 from idlelib.config import idleConf
23 from idlelib.textview import view_text
24 from idlelib.tooltip import Hovertip
25 from idlelib import macosx
26
27
28 def count_lines_with_wrapping(s, linewidth=80):
29 """Count the number of lines in a given string.
30
31 Lines are counted as if the string was wrapped so that lines are never over
32 linewidth characters long.
33
34 Tabs are considered tabwidth characters long.
35 """
36 tabwidth = 8 # Currently always true in Shell.
37 pos = 0
38 linecount = 1
39 current_column = 0
40
41 for m in re.finditer(r"[\t\n]", s):
42 # Process the normal chars up to tab or newline.
43 numchars = m.start() - pos
44 pos += numchars
45 current_column += numchars
46
47 # Deal with tab or newline.
48 if s[pos] == '\n':
49 # Avoid the `current_column == 0` edge-case, and while we're
50 # at it, don't bother adding 0.
51 if current_column > linewidth:
52 # If the current column was exactly linewidth, divmod
53 # would give (1,0), even though a new line hadn't yet
54 # been started. The same is true if length is any exact
55 # multiple of linewidth. Therefore, subtract 1 before
56 # dividing a non-empty line.
57 linecount += (current_column - 1) // linewidth
58 linecount += 1
59 current_column = 0
60 else:
61 assert s[pos] == '\t'
62 current_column += tabwidth - (current_column % tabwidth)
63
64 # If a tab passes the end of the line, consider the entire
65 # tab as being on the next line.
66 if current_column > linewidth:
67 linecount += 1
68 current_column = tabwidth
69
70 pos += 1 # After the tab or newline.
71
72 # Process remaining chars (no more tabs or newlines).
73 current_column += len(s) - pos
74 # Avoid divmod(-1, linewidth).
75 if current_column > 0:
76 linecount += (current_column - 1) // linewidth
77 else:
78 # Text ended with newline; don't count an extra line after it.
79 linecount -= 1
80
81 return linecount
82
83
84 class ExpandingButton(tk.Button):
85 """Class for the "squeezed" text buttons used by Squeezer
86
87 These buttons are displayed inside a Tk Text widget in place of text. A
88 user can then use the button to replace it with the original text, copy
89 the original text to the clipboard or view the original text in a separate
90 window.
91
92 Each button is tied to a Squeezer instance, and it knows to update the
93 Squeezer instance when it is expanded (and therefore removed).
94 """
95 def __init__(self, s, tags, numoflines, squeezer):
96 self.s = s
97 self.tags = tags
98 self.numoflines = numoflines
99 self.squeezer = squeezer
100 self.editwin = editwin = squeezer.editwin
101 self.text = text = editwin.text
102 # The base Text widget is needed to change text before iomark.
103 self.base_text = editwin.per.bottom
104
105 line_plurality = "lines" if numoflines != 1 else "line"
106 button_text = f"Squeezed text ({numoflines} {line_plurality})."
107 tk.Button.__init__(self, text, text=button_text,
108 background="#FFFFC0", activebackground="#FFFFE0")
109
110 button_tooltip_text = (
111 "Double-click to expand, right-click for more options."
112 )
113 Hovertip(self, button_tooltip_text, hover_delay=80)
114
115 self.bind("<Double-Button-1>", self.expand)
116 if macosx.isAquaTk():
117 # AquaTk defines <2> as the right button, not <3>.
118 self.bind("<Button-2>", self.context_menu_event)
119 else:
120 self.bind("<Button-3>", self.context_menu_event)
121 self.selection_handle( # X windows only.
122 lambda offset, length: s[int(offset):int(offset) + int(length)])
123
124 self.is_dangerous = None
125 self.after_idle(self.set_is_dangerous)
126
127 def set_is_dangerous(self):
128 dangerous_line_len = 50 * self.text.winfo_width()
129 self.is_dangerous = (
130 self.numoflines > 1000 or
131 len(self.s) > 50000 or
132 any(
133 len(line_match.group(0)) >= dangerous_line_len
134 for line_match in re.finditer(r'[^\n]+', self.s)
135 )
136 )
137
138 def expand(self, event=None):
139 """expand event handler
140
141 This inserts the original text in place of the button in the Text
142 widget, removes the button and updates the Squeezer instance.
143
144 If the original text is dangerously long, i.e. expanding it could
145 cause a performance degradation, ask the user for confirmation.
146 """
147 if self.is_dangerous is None:
148 self.set_is_dangerous()
149 if self.is_dangerous:
150 confirm = tkMessageBox.askokcancel(
151 title="Expand huge output?",
152 message="\n\n".join([
153 "The squeezed output is very long: %d lines, %d chars.",
154 "Expanding it could make IDLE slow or unresponsive.",
155 "It is recommended to view or copy the output instead.",
156 "Really expand?"
157 ]) % (self.numoflines, len(self.s)),
158 default=tkMessageBox.CANCEL,
159 parent=self.text)
160 if not confirm:
161 return "break"
162
163 self.base_text.insert(self.text.index(self), self.s, self.tags)
164 self.base_text.delete(self)
165 self.squeezer.expandingbuttons.remove(self)
166
167 def copy(self, event=None):
168 """copy event handler
169
170 Copy the original text to the clipboard.
171 """
172 self.clipboard_clear()
173 self.clipboard_append(self.s)
174
175 def view(self, event=None):
176 """view event handler
177
178 View the original text in a separate text viewer window.
179 """
180 view_text(self.text, "Squeezed Output Viewer", self.s,
181 modal=False, wrap='none')
182
183 rmenu_specs = (
184 # Item structure: (label, method_name).
185 ('copy', 'copy'),
186 ('view', 'view'),
187 )
188
189 def context_menu_event(self, event):
190 self.text.mark_set("insert", "@%d,%d" % (event.x, event.y))
191 rmenu = tk.Menu(self.text, tearoff=0)
192 for label, method_name in self.rmenu_specs:
193 rmenu.add_command(label=label, command=getattr(self, method_name))
194 rmenu.tk_popup(event.x_root, event.y_root)
195 return "break"
196
197
198 class Squeezer:
199 """Replace long outputs in the shell with a simple button.
200
201 This avoids IDLE's shell slowing down considerably, and even becoming
202 completely unresponsive, when very long outputs are written.
203 """
204 @classmethod
205 def reload(cls):
206 """Load class variables from config."""
207 cls.auto_squeeze_min_lines = idleConf.GetOption(
208 "main", "PyShell", "auto-squeeze-min-lines",
209 type="int", default=50,
210 )
211
212 def __init__(self, editwin):
213 """Initialize settings for Squeezer.
214
215 editwin is the shell's Editor window.
216 self.text is the editor window text widget.
217 self.base_test is the actual editor window Tk text widget, rather than
218 EditorWindow's wrapper.
219 self.expandingbuttons is the list of all buttons representing
220 "squeezed" output.
221 """
222 self.editwin = editwin
223 self.text = text = editwin.text
224
225 # Get the base Text widget of the PyShell object, used to change
226 # text before the iomark. PyShell deliberately disables changing
227 # text before the iomark via its 'text' attribute, which is
228 # actually a wrapper for the actual Text widget. Squeezer,
229 # however, needs to make such changes.
230 self.base_text = editwin.per.bottom
231
232 # Twice the text widget's border width and internal padding;
233 # pre-calculated here for the get_line_width() method.
234 self.window_width_delta = 2 * (
235 int(text.cget('border')) +
236 int(text.cget('padx'))
237 )
238
239 self.expandingbuttons = []
240
241 # Replace the PyShell instance's write method with a wrapper,
242 # which inserts an ExpandingButton instead of a long text.
243 def mywrite(s, tags=(), write=editwin.write):
244 # Only auto-squeeze text which has just the "stdout" tag.
245 if tags != "stdout":
246 return write(s, tags)
247
248 # Only auto-squeeze text with at least the minimum
249 # configured number of lines.
250 auto_squeeze_min_lines = self.auto_squeeze_min_lines
251 # First, a very quick check to skip very short texts.
252 if len(s) < auto_squeeze_min_lines:
253 return write(s, tags)
254 # Now the full line-count check.
255 numoflines = self.count_lines(s)
256 if numoflines < auto_squeeze_min_lines:
257 return write(s, tags)
258
259 # Create an ExpandingButton instance.
260 expandingbutton = ExpandingButton(s, tags, numoflines, self)
261
262 # Insert the ExpandingButton into the Text widget.
263 text.mark_gravity("iomark", tk.RIGHT)
264 text.window_create("iomark", window=expandingbutton,
265 padx=3, pady=5)
266 text.see("iomark")
267 text.update()
268 text.mark_gravity("iomark", tk.LEFT)
269
270 # Add the ExpandingButton to the Squeezer's list.
271 self.expandingbuttons.append(expandingbutton)
272
273 editwin.write = mywrite
274
275 def count_lines(self, s):
276 """Count the number of lines in a given text.
277
278 Before calculation, the tab width and line length of the text are
279 fetched, so that up-to-date values are used.
280
281 Lines are counted as if the string was wrapped so that lines are never
282 over linewidth characters long.
283
284 Tabs are considered tabwidth characters long.
285 """
286 return count_lines_with_wrapping(s, self.editwin.width)
287
288 def squeeze_current_text_event(self, event):
289 """squeeze-current-text event handler
290
291 Squeeze the block of text inside which contains the "insert" cursor.
292
293 If the insert cursor is not in a squeezable block of text, give the
294 user a small warning and do nothing.
295 """
296 # Set tag_name to the first valid tag found on the "insert" cursor.
297 tag_names = self.text.tag_names(tk.INSERT)
298 for tag_name in ("stdout", "stderr"):
299 if tag_name in tag_names:
300 break
301 else:
302 # The insert cursor doesn't have a "stdout" or "stderr" tag.
303 self.text.bell()
304 return "break"
305
306 # Find the range to squeeze.
307 start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
308 s = self.text.get(start, end)
309
310 # If the last char is a newline, remove it from the range.
311 if len(s) > 0 and s[-1] == '\n':
312 end = self.text.index("%s-1c" % end)
313 s = s[:-1]
314
315 # Delete the text.
316 self.base_text.delete(start, end)
317
318 # Prepare an ExpandingButton.
319 numoflines = self.count_lines(s)
320 expandingbutton = ExpandingButton(s, tag_name, numoflines, self)
321
322 # insert the ExpandingButton to the Text
323 self.text.window_create(start, window=expandingbutton,
324 padx=3, pady=5)
325
326 # Insert the ExpandingButton to the list of ExpandingButtons,
327 # while keeping the list ordered according to the position of
328 # the buttons in the Text widget.
329 i = len(self.expandingbuttons)
330 while i > 0 and self.text.compare(self.expandingbuttons[i-1],
331 ">", expandingbutton):
332 i -= 1
333 self.expandingbuttons.insert(i, expandingbutton)
334
335 return "break"
336
337
338 Squeezer.reload()
339
340
341 if __name__ == "__main__":
342 from unittest import main
343 main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False)
344
345 # Add htest.