Coverage for squeezer.py: 62%

135 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-11 13:22 -0700

1"""An IDLE extension to avoid having very long texts printed in the shell. 

2 

3A common problem in IDLE's interactive shell is printing of large amounts of 

4text into the shell. This makes looking at the previous history difficult. 

5Worse, this can cause IDLE to become very slow, even to the point of being 

6completely unusable. 

7 

8This extension will automatically replace long texts with a small button. 

9Double-clicking this button will remove it and insert the original text instead. 

10Middle-clicking will copy the text to the clipboard. Right-clicking will open 

11the text in a separate viewing window. 

12 

13Additionally, any output can be manually "squeezed" by the user. This includes 

14output written to the standard error stream ("stderr"), such as exception 

15messages and their tracebacks. 

16""" 

17import re 

18 

19import tkinter as tk 

20from tkinter import messagebox 

21 

22from idlelib.config import idleConf 

23from idlelib.textview import view_text 

24from idlelib.tooltip import Hovertip 

25from idlelib import macosx 

26 

27 

28def 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. 1fjghideba

37 pos = 0 1fjghideba

38 linecount = 1 1fjghideba

39 current_column = 0 1fjghideba

40 

41 for m in re.finditer(r"[\t\n]", s): 1fjghideba

42 # Process the normal chars up to tab or newline. 

43 numchars = m.start() - pos 1fghideba

44 pos += numchars 1fghideba

45 current_column += numchars 1fghideba

46 

47 # Deal with tab or newline. 

48 if s[pos] == '\n': 1fghideba

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: 1fghideba

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 1deb

58 linecount += 1 1fghideba

59 current_column = 0 1fghideba

60 else: 

61 assert s[pos] == '\t' 1b

62 current_column += tabwidth - (current_column % tabwidth) 1b

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: 1b

67 linecount += 1 1b

68 current_column = tabwidth 1b

69 

70 pos += 1 # After the tab or newline. 1fghideba

71 

72 # Process remaining chars (no more tabs or newlines). 

73 current_column += len(s) - pos 1fjghideba

74 # Avoid divmod(-1, linewidth). 

75 if current_column > 0: 1fjghideba

76 linecount += (current_column - 1) // linewidth 1fdeba

77 else: 

78 # Text ended with newline; don't count an extra line after it. 

79 linecount -= 1 1jghideba

80 

81 return linecount 1fjghideba

82 

83 

84class 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 1a

97 self.tags = tags 1a

98 self.numoflines = numoflines 1a

99 self.squeezer = squeezer 1a

100 self.editwin = editwin = squeezer.editwin 1a

101 self.text = text = editwin.text 1a

102 # The base Text widget is needed to change text before iomark. 

103 self.base_text = editwin.per.bottom 1a

104 

105 line_plurality = "lines" if numoflines != 1 else "line" 1a

106 button_text = f"Squeezed text ({numoflines} {line_plurality})." 1a

107 tk.Button.__init__(self, text, text=button_text, 1a

108 background="#FFFFC0", activebackground="#FFFFE0") 

109 

110 button_tooltip_text = ( 1a

111 "Double-click to expand, right-click for more options." 

112 ) 

113 Hovertip(self, button_tooltip_text, hover_delay=80) 1a

114 

115 self.bind("<Double-Button-1>", self.expand) 1a

116 if macosx.isAquaTk(): 116 ↛ 120line 116 didn't jump to line 120, because the condition on line 116 was never false1a

117 # AquaTk defines <2> as the right button, not <3>. 

118 self.bind("<Button-2>", self.context_menu_event) 1a

119 else: 

120 self.bind("<Button-3>", self.context_menu_event) 

121 self.selection_handle( # X windows only. 121 ↛ exitline 121 didn't jump to the function exit1a

122 lambda offset, length: s[int(offset):int(offset) + int(length)]) 

123 

124 self.is_dangerous = None 1a

125 self.after_idle(self.set_is_dangerous) 1a

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 = messagebox.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=messagebox.CANCEL, 

159 parent=self.text) 

160 if not confirm: 

161 return "break" 

162 

163 index = self.text.index(self) 

164 self.base_text.insert(index, self.s, self.tags) 

165 self.base_text.delete(self) 

166 self.editwin.on_squeezed_expand(index, self.s, self.tags) 

167 self.squeezer.expandingbuttons.remove(self) 

168 

169 def copy(self, event=None): 

170 """copy event handler 

171 

172 Copy the original text to the clipboard. 

173 """ 

174 self.clipboard_clear() 

175 self.clipboard_append(self.s) 

176 

177 def view(self, event=None): 

178 """view event handler 

179 

180 View the original text in a separate text viewer window. 

181 """ 

182 view_text(self.text, "Squeezed Output Viewer", self.s, 

183 modal=False, wrap='none') 

184 

185 rmenu_specs = ( 

186 # Item structure: (label, method_name). 

187 ('copy', 'copy'), 

188 ('view', 'view'), 

189 ) 

190 

191 def context_menu_event(self, event): 

192 self.text.mark_set("insert", "@%d,%d" % (event.x, event.y)) 

193 rmenu = tk.Menu(self.text, tearoff=0) 

194 for label, method_name in self.rmenu_specs: 

195 rmenu.add_command(label=label, command=getattr(self, method_name)) 

196 rmenu.tk_popup(event.x_root, event.y_root) 

197 return "break" 

198 

199 

200class Squeezer: 

201 """Replace long outputs in the shell with a simple button. 

202 

203 This avoids IDLE's shell slowing down considerably, and even becoming 

204 completely unresponsive, when very long outputs are written. 

205 """ 

206 @classmethod 

207 def reload(cls): 

208 """Load class variables from config.""" 

209 cls.auto_squeeze_min_lines = idleConf.GetOption( 1cn

210 "main", "PyShell", "auto-squeeze-min-lines", 

211 type="int", default=50, 

212 ) 

213 

214 def __init__(self, editwin): 

215 """Initialize settings for Squeezer. 

216 

217 editwin is the shell's Editor window. 

218 self.text is the editor window text widget. 

219 self.base_test is the actual editor window Tk text widget, rather than 

220 EditorWindow's wrapper. 

221 self.expandingbuttons is the list of all buttons representing 

222 "squeezed" output. 

223 """ 

224 self.editwin = editwin 1bmkla

225 self.text = text = editwin.text 1bmkla

226 

227 # Get the base Text widget of the PyShell object, used to change 

228 # text before the iomark. PyShell deliberately disables changing 

229 # text before the iomark via its 'text' attribute, which is 

230 # actually a wrapper for the actual Text widget. Squeezer, 

231 # however, needs to make such changes. 

232 self.base_text = editwin.per.bottom 1bmkla

233 

234 # Twice the text widget's border width and internal padding; 

235 # pre-calculated here for the get_line_width() method. 

236 self.window_width_delta = 2 * ( 1bmkla

237 int(text.cget('border')) + 

238 int(text.cget('padx')) 

239 ) 

240 

241 self.expandingbuttons = [] 1bmkla

242 

243 # Replace the PyShell instance's write method with a wrapper, 

244 # which inserts an ExpandingButton instead of a long text. 

245 def mywrite(s, tags=(), write=editwin.write): 1bmkla

246 # Only auto-squeeze text which has just the "stdout" tag. 

247 if tags != "stdout": 1kla

248 return write(s, tags) 1kl

249 

250 # Only auto-squeeze text with at least the minimum 

251 # configured number of lines. 

252 auto_squeeze_min_lines = self.auto_squeeze_min_lines 1a

253 # First, a very quick check to skip very short texts. 

254 if len(s) < auto_squeeze_min_lines: 1a

255 return write(s, tags) 1a

256 # Now the full line-count check. 

257 numoflines = self.count_lines(s) 1a

258 if numoflines < auto_squeeze_min_lines: 258 ↛ 259line 258 didn't jump to line 259, because the condition on line 258 was never true1a

259 return write(s, tags) 

260 

261 # Create an ExpandingButton instance. 

262 expandingbutton = ExpandingButton(s, tags, numoflines, self) 1a

263 

264 # Insert the ExpandingButton into the Text widget. 

265 text.mark_gravity("iomark", tk.RIGHT) 1a

266 text.window_create("iomark", window=expandingbutton, 1a

267 padx=3, pady=5) 

268 text.see("iomark") 1a

269 text.update() 1a

270 text.mark_gravity("iomark", tk.LEFT) 1a

271 

272 # Add the ExpandingButton to the Squeezer's list. 

273 self.expandingbuttons.append(expandingbutton) 1a

274 

275 editwin.write = mywrite 1bmkla

276 

277 def count_lines(self, s): 

278 """Count the number of lines in a given text. 

279 

280 Before calculation, the tab width and line length of the text are 

281 fetched, so that up-to-date values are used. 

282 

283 Lines are counted as if the string was wrapped so that lines are never 

284 over linewidth characters long. 

285 

286 Tabs are considered tabwidth characters long. 

287 """ 

288 return count_lines_with_wrapping(s, self.editwin.width) 1ba

289 

290 def squeeze_current_text(self): 

291 """Squeeze the text block where the insertion cursor is. 

292 

293 If the 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 

338Squeezer.reload() 

339 

340 

341if __name__ == "__main__": 341 ↛ 342line 341 didn't jump to line 342, because the condition on line 341 was never true

342 from unittest import main 

343 main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False) 

344 

345 # Add htest.