Coverage for query.py: 48%

250 statements  

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

1""" 

2Dialogs that query users and verify the answer before accepting. 

3 

4Query is the generic base class for a popup dialog. 

5The user must either enter a valid answer or close the dialog. 

6Entries are validated when <Return> is entered or [Ok] is clicked. 

7Entries are ignored when [Cancel] or [X] are clicked. 

8The 'return value' is .result set to either a valid answer or None. 

9 

10Subclass SectionName gets a name for a new config file section. 

11Configdialog uses it for new highlight theme and keybinding set names. 

12Subclass ModuleName gets a name for File => Open Module. 

13Subclass HelpSource gets menu item and path for additions to Help menu. 

14""" 

15# Query and Section name result from splitting GetCfgSectionNameDialog 

16# of configSectionNameDialog.py (temporarily config_sec.py) into 

17# generic and specific parts. 3.6 only, July 2016. 

18# ModuleName.entry_ok came from editor.EditorWindow.load_module. 

19# HelpSource was extracted from configHelpSourceEdit.py (temporarily 

20# config_help.py), with darwin code moved from ok to path_ok. 

21 

22import importlib.util, importlib.abc 

23import os 

24import shlex 

25from sys import executable, platform # Platform is set for one test. 

26 

27from tkinter import Toplevel, StringVar, BooleanVar, W, E, S 

28from tkinter.ttk import Frame, Button, Entry, Label, Checkbutton 

29from tkinter import filedialog 

30from tkinter.font import Font 

31from tkinter.simpledialog import _setup_dialog 

32 

33class Query(Toplevel): 

34 """Base class for getting verified answer from a user. 

35 

36 For this base class, accept any non-blank string. 

37 """ 

38 def __init__(self, parent, title, message, *, text0='', used_names={}, 

39 _htest=False, _utest=False): 

40 """Create modal popup, return when destroyed. 

41 

42 Additional subclass init must be done before this unless 

43 _utest=True is passed to suppress wait_window(). 

44 

45 title - string, title of popup dialog 

46 message - string, informational message to display 

47 text0 - initial value for entry 

48 used_names - names already in use 

49 _htest - bool, change box location when running htest 

50 _utest - bool, leave window hidden and not modal 

51 """ 

52 self.parent = parent # Needed for Font call. 

53 self.message = message 

54 self.text0 = text0 

55 self.used_names = used_names 

56 

57 Toplevel.__init__(self, parent) 

58 self.withdraw() # Hide while configuring, especially geometry. 

59 self.title(title) 

60 self.transient(parent) 

61 if not _utest: # Otherwise fail when directly run unittest. 

62 self.grab_set() 

63 

64 _setup_dialog(self) 

65 if self._windowingsystem == 'aqua': 

66 self.bind("<Command-.>", self.cancel) 

67 self.bind('<Key-Escape>', self.cancel) 

68 self.protocol("WM_DELETE_WINDOW", self.cancel) 

69 self.bind('<Key-Return>', self.ok) 

70 self.bind("<KP_Enter>", self.ok) 

71 

72 self.create_widgets() 

73 self.update_idletasks() # Need here for winfo_reqwidth below. 

74 self.geometry( # Center dialog over parent (or below htest box). 

75 "+%d+%d" % ( 

76 parent.winfo_rootx() + 

77 (parent.winfo_width()/2 - self.winfo_reqwidth()/2), 

78 parent.winfo_rooty() + 

79 ((parent.winfo_height()/2 - self.winfo_reqheight()/2) 

80 if not _htest else 150) 

81 ) ) 

82 self.resizable(height=False, width=False) 

83 

84 if not _utest: 

85 self.deiconify() # Unhide now that geometry set. 

86 self.entry.focus_set() 

87 self.wait_window() 

88 

89 def create_widgets(self, ok_text='OK'): # Do not replace. 

90 """Create entry (rows, extras, buttons. 

91 

92 Entry stuff on rows 0-2, spanning cols 0-2. 

93 Buttons on row 99, cols 1, 2. 

94 """ 

95 # Bind to self the widgets needed for entry_ok or unittest. 

96 self.frame = frame = Frame(self, padding=10) 

97 frame.grid(column=0, row=0, sticky='news') 

98 frame.grid_columnconfigure(0, weight=1) 

99 

100 entrylabel = Label(frame, anchor='w', justify='left', 

101 text=self.message) 

102 self.entryvar = StringVar(self, self.text0) 

103 self.entry = Entry(frame, width=30, textvariable=self.entryvar) 

104 self.error_font = Font(name='TkCaptionFont', 

105 exists=True, root=self.parent) 

106 self.entry_error = Label(frame, text=' ', foreground='red', 

107 font=self.error_font) 

108 # Display or blank error by setting ['text'] =. 

109 entrylabel.grid(column=0, row=0, columnspan=3, padx=5, sticky=W) 

110 self.entry.grid(column=0, row=1, columnspan=3, padx=5, sticky=W+E, 

111 pady=[10,0]) 

112 self.entry_error.grid(column=0, row=2, columnspan=3, padx=5, 

113 sticky=W+E) 

114 

115 self.create_extra() 

116 

117 self.button_ok = Button( 

118 frame, text=ok_text, default='active', command=self.ok) 

119 self.button_cancel = Button( 

120 frame, text='Cancel', command=self.cancel) 

121 

122 self.button_ok.grid(column=1, row=99, padx=5) 

123 self.button_cancel.grid(column=2, row=99, padx=5) 

124 

125 def create_extra(self): pass # Override to add widgets. 125 ↛ exitline 125 didn't return from function 'create_extra'

126 

127 def showerror(self, message, widget=None): 

128 #self.bell(displayof=self) 

129 (widget or self.entry_error)['text'] = 'ERROR: ' + message 

130 

131 def hideerror(self, widget=None): 

132 (widget or self.entry_error)['text'] = '' 

133 

134 def entry_ok(self): # Example: usually replace. 

135 "Return non-blank entry or None." 

136 entry = self.entry.get().strip() 1nvde

137 if not entry: 1nvde

138 self.showerror('blank line.') 1nd

139 return None 1nd

140 return entry 1ve

141 

142 def ok(self, event=None): # Do not replace. 

143 '''If entry is valid, bind it to 'result' and destroy tk widget. 

144 

145 Otherwise leave dialog open for user to correct entry or cancel. 

146 ''' 

147 self.entry_error['text'] = '' 1de

148 entry = self.entry_ok() 1de

149 if entry is not None: 1de

150 self.result = entry 1e

151 self.destroy() 1e

152 else: 

153 # [Ok] moves focus. (<Return> does not.) Move it back. 

154 self.entry.focus_set() 1d

155 

156 def cancel(self, event=None): # Do not replace. 

157 "Set dialog result to None and destroy tk widget." 

158 self.result = None 1x

159 self.destroy() 1x

160 

161 def destroy(self): 

162 self.grab_release() 

163 super().destroy() 

164 

165 

166class SectionName(Query): 

167 "Get a name for a config file section name." 

168 # Used in ConfigDialog.GetNewKeysName, .GetNewThemeName (837) 

169 

170 def __init__(self, parent, title, message, used_names, 

171 *, _htest=False, _utest=False): 

172 super().__init__(parent, title, message, used_names=used_names, 

173 _htest=_htest, _utest=_utest) 

174 

175 def entry_ok(self): 

176 "Return sensible ConfigParser section name or None." 

177 name = self.entry.get().strip() 1olmj

178 if not name: 1olmj

179 self.showerror('no name specified.') 1o

180 return None 1o

181 elif len(name)>30: 1lmj

182 self.showerror('name is longer than 30 characters.') 1m

183 return None 1m

184 elif name in self.used_names: 1lj

185 self.showerror('name is already in use.') 1j

186 return None 1j

187 return name 1l

188 

189 

190class ModuleName(Query): 

191 "Get a module name for Open Module menu entry." 

192 # Used in open_module (editor.EditorWindow until move to iobinding). 

193 

194 def __init__(self, parent, title, message, text0, 

195 *, _htest=False, _utest=False): 

196 super().__init__(parent, title, message, text0=text0, 

197 _htest=_htest, _utest=_utest) 

198 

199 def entry_ok(self): 

200 "Return entered module name as file path or None." 

201 name = self.entry.get().strip() 1phfc

202 if not name: 1phfc

203 self.showerror('no name specified.') 1p

204 return None 1p

205 # XXX Ought to insert current file's directory in front of path. 

206 try: 1hfc

207 spec = importlib.util.find_spec(name) 1hfc

208 except (ValueError, ImportError) as msg: 

209 self.showerror(str(msg)) 

210 return None 

211 if spec is None: 1hfc

212 self.showerror("module not found.") 1h

213 return None 1h

214 if not isinstance(spec.loader, importlib.abc.SourceLoader): 1fc

215 self.showerror("not a source-based module.") 1f

216 return None 1f

217 try: 1c

218 file_path = spec.loader.get_filename(name) 1c

219 except AttributeError: 

220 self.showerror("loader does not support get_filename.") 

221 return None 

222 except ImportError: 

223 # Some special modules require this (e.g. os.path) 

224 try: 

225 file_path = spec.loader.get_filename() 

226 except TypeError: 

227 self.showerror("loader failed to get filename.") 

228 return None 

229 return file_path 1c

230 

231 

232class Goto(Query): 

233 "Get a positive line number for editor Go To Line or a query of format 'line:char'" 

234 # Used in editor.EditorWindow.goto_line_event. 

235 def __init__(self, parent, title, message, *, linecount=0, editwin=None, _htest=False, _utest=False): 

236 self.editwin = editwin 

237 self.linecount = linecount 

238 # Set _utest to True, so we can slightly modify the superclass now. 

239 super().__init__( 

240 parent, title, message, _htest=_htest, _utest=True) 

241 

242 # We need a function that allows us to update the cursor position live as the 

243 # user types into the dialog box. This callback accomplishes this live update. 

244 def write_trace_callback(name, index, mode): 

245 self.editwin.goto_line(self.entry_ok()) 

246 # Setup trace so we can hook into functionality to make sure Goto tells the editor window 

247 # to update the cursor position whenever we change the text in the dialog box. 

248 # 

249 # Note that we use `entry_ok` as the value, since this function also implements error checking. 

250 self.entryvar.trace_add("write", lambda x, y, z: self.editwin.goto_line(self.entry_ok())) 

251 # Now activate window. 

252 # This is what the superclass does if _utest is False, but we need to do it after we've set up the trace 

253 # since we disabled exactly this. 

254 self.deiconify() 

255 self.entry.focus_set() 

256 self.wait_window() 

257 

258 def entry_ok(self): 

259 """ 

260 Return entered line number, or line and character index or None. 

261 

262 Performs error checking to make sure values entered are valid. 

263 """ 

264 # If error message is live, remove it for now. 

265 # We'll add it if the current value is bad. 

266 self.hideerror() 

267 entry = self.entry.get() 

268 # Check for character indexing format. 

269 if ":" not in entry: 

270 try: 

271 lineno = int(self.entry.get()) 

272 except ValueError: 

273 self.showerror('Not a base 10 integer.') 

274 return None 

275 if lineno > self.linecount or lineno == 0: 

276 self.showerror(f"Enter a number between 1 and {self.linecount}, inclusive") 

277 return None 

278 if lineno < -self.linecount: 

279 self.showerror(f"Negative entries must be between -{self.linecount} and -1, inclusive") 

280 return None 

281 return self.entry.get() 

282 else: 

283 try: 

284 line = int(entry.split(":")[0]) 

285 except ValueError: 

286 self.showerror('Line index is not a base 10 integer.') 

287 return None 

288 if line > self.linecount or line == 0: 

289 self.showerror(f"Enter a number between 1 and {self.linecount}, inclusive") 

290 return None 

291 if line < -self.linecount: 

292 self.showerror(f"Negative entries must be between -{self.linecount} and -1, inclusive") 

293 return None 

294 try: 

295 char = int(entry.split(":")[1]) 

296 except ValueError: 

297 self.showerror('Character index is not a base 10 integer.') 

298 return None 

299 if char <= 0: 

300 self.showerror(f"Character index must be a positive number.") 

301 return None 

302 return f"{line}:{char}" 

303 

304 

305 

306class HelpSource(Query): 

307 "Get menu name and help source for Help menu." 

308 # Used in ConfigDialog.HelpListItemAdd/Edit, (941/9) 

309 

310 def __init__(self, parent, title, *, menuitem='', filepath='', 

311 used_names={}, _htest=False, _utest=False): 

312 """Get menu entry and url/local file for Additional Help. 

313 

314 User enters a name for the Help resource and a web url or file 

315 name. The user can browse for the file. 

316 """ 

317 self.filepath = filepath 

318 message = 'Name for item on Help menu:' 

319 super().__init__( 

320 parent, title, message, text0=menuitem, 

321 used_names=used_names, _htest=_htest, _utest=_utest) 

322 

323 def create_extra(self): 

324 "Add path widjets to rows 10-12." 

325 frame = self.frame 

326 pathlabel = Label(frame, anchor='w', justify='left', 

327 text='Help File Path: Enter URL or browse for file') 

328 self.pathvar = StringVar(self, self.filepath) 

329 self.path = Entry(frame, textvariable=self.pathvar, width=40) 

330 browse = Button(frame, text='Browse', width=8, 

331 command=self.browse_file) 

332 self.path_error = Label(frame, text=' ', foreground='red', 

333 font=self.error_font) 

334 

335 pathlabel.grid(column=0, row=10, columnspan=3, padx=5, pady=[10,0], 

336 sticky=W) 

337 self.path.grid(column=0, row=11, columnspan=2, padx=5, sticky=W+E, 

338 pady=[10,0]) 

339 browse.grid(column=2, row=11, padx=5, sticky=W+S) 

340 self.path_error.grid(column=0, row=12, columnspan=3, padx=5, 

341 sticky=W+E) 

342 

343 def askfilename(self, filetypes, initdir, initfile): # htest # 

344 # Extracted from browse_file so can mock for unittests. 

345 # Cannot unittest as cannot simulate button clicks. 

346 # Test by running htest, such as by running this file. 

347 return filedialog.Open(parent=self, filetypes=filetypes)\ 

348 .show(initialdir=initdir, initialfile=initfile) 

349 

350 def browse_file(self): 

351 filetypes = [ 1b

352 ("HTML Files", "*.htm *.html", "TEXT"), 

353 ("PDF Files", "*.pdf", "TEXT"), 

354 ("Windows Help Files", "*.chm"), 

355 ("Text Files", "*.txt", "TEXT"), 

356 ("All Files", "*")] 

357 path = self.pathvar.get() 1b

358 if path: 1b

359 dir, base = os.path.split(path) 1b

360 else: 

361 base = None 1b

362 if platform[:3] == 'win': 362 ↛ 363line 362 didn't jump to line 363, because the condition on line 362 was never true1b

363 dir = os.path.join(os.path.dirname(executable), 'Doc') 

364 if not os.path.isdir(dir): 

365 dir = os.getcwd() 

366 else: 

367 dir = os.getcwd() 1b

368 file = self.askfilename(filetypes, dir, base) 1b

369 if file: 1b

370 self.pathvar.set(file) 1b

371 

372 item_ok = SectionName.entry_ok # localize for test override 

373 

374 def path_ok(self): 

375 "Simple validity check for menu file path" 

376 path = self.path.get().strip() 1iqgr

377 if not path: #no path specified 1iqgr

378 self.showerror('no help file path specified.', self.path_error) 1q

379 return None 1q

380 elif not path.startswith(('www.', 'http')): 1igr

381 if path[:5] == 'file:': 381 ↛ 382line 381 didn't jump to line 382, because the condition on line 381 was never true1ig

382 path = path[5:] 

383 if not os.path.exists(path): 1ig

384 self.showerror('help file path does not exist.', 1i

385 self.path_error) 

386 return None 1i

387 if platform == 'darwin': # for Mac Safari 1g

388 path = "file://" + path 1g

389 return path 1gr

390 

391 def entry_ok(self): 

392 "Return apparently valid (name, path) or None" 

393 self.path_error['text'] = '' 1s

394 name = self.item_ok() 1s

395 path = self.path_ok() 1s

396 return None if name is None or path is None else (name, path) 1s

397 

398class CustomRun(Query): 

399 """Get settings for custom run of module. 

400 

401 1. Command line arguments to extend sys.argv. 

402 2. Whether to restart Shell or not. 

403 """ 

404 # Used in runscript.run_custom_event 

405 

406 def __init__(self, parent, title, *, cli_args=[], 

407 _htest=False, _utest=False): 

408 """cli_args is a list of strings. 

409 

410 The list is assigned to the default Entry StringVar. 

411 The strings are displayed joined by ' ' for display. 

412 """ 

413 message = 'Command Line Arguments for sys.argv:' 

414 super().__init__( 

415 parent, title, message, text0=cli_args, 

416 _htest=_htest, _utest=_utest) 

417 

418 def create_extra(self): 

419 "Add run mode on rows 10-12." 

420 frame = self.frame 

421 self.restartvar = BooleanVar(self, value=True) 

422 restart = Checkbutton(frame, variable=self.restartvar, onvalue=True, 

423 offvalue=False, text='Restart shell') 

424 self.args_error = Label(frame, text=' ', foreground='red', 

425 font=self.error_font) 

426 

427 restart.grid(column=0, row=10, columnspan=3, padx=5, sticky='w') 

428 self.args_error.grid(column=0, row=12, columnspan=3, padx=5, 

429 sticky='we') 

430 

431 def cli_args_ok(self): 

432 "Validity check and parsing for command line arguments." 

433 cli_string = self.entry.get().strip() 1tuk

434 try: 1tuk

435 cli_args = shlex.split(cli_string, posix=True) 1tuk

436 except ValueError as err: 1k

437 self.showerror(str(err)) 1k

438 return None 1k

439 return cli_args 1tu

440 

441 def entry_ok(self): 

442 "Return apparently valid (cli_args, restart) or None." 

443 cli_args = self.cli_args_ok() 1w

444 restart = self.restartvar.get() 1w

445 return None if cli_args is None else (cli_args, restart) 1w

446 

447 

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

449 from unittest import main 

450 main('idlelib.idle_test.test_query', verbosity=2, exit=False) 

451 

452 from idlelib.idle_test.htest import run 

453 run(Query, HelpSource, CustomRun)