query.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. """
  2. Dialogs that query users and verify the answer before accepting.
  3. Query is the generic base class for a popup dialog.
  4. The user must either enter a valid answer or close the dialog.
  5. Entries are validated when <Return> is entered or [Ok] is clicked.
  6. Entries are ignored when [Cancel] or [X] are clicked.
  7. The 'return value' is .result set to either a valid answer or None.
  8. Subclass SectionName gets a name for a new config file section.
  9. Configdialog uses it for new highlight theme and keybinding set names.
  10. Subclass ModuleName gets a name for File => Open Module.
  11. Subclass HelpSource gets menu item and path for additions to Help menu.
  12. """
  13. # Query and Section name result from splitting GetCfgSectionNameDialog
  14. # of configSectionNameDialog.py (temporarily config_sec.py) into
  15. # generic and specific parts. 3.6 only, July 2016.
  16. # ModuleName.entry_ok came from editor.EditorWindow.load_module.
  17. # HelpSource was extracted from configHelpSourceEdit.py (temporarily
  18. # config_help.py), with darwin code moved from ok to path_ok.
  19. import importlib.util, importlib.abc
  20. import os
  21. import shlex
  22. from sys import executable, platform # Platform is set for one test.
  23. from tkinter import Toplevel, StringVar, BooleanVar, W, E, S
  24. from tkinter.ttk import Frame, Button, Entry, Label, Checkbutton
  25. from tkinter import filedialog
  26. from tkinter.font import Font
  27. from tkinter.simpledialog import _setup_dialog
  28. class Query(Toplevel):
  29. """Base class for getting verified answer from a user.
  30. For this base class, accept any non-blank string.
  31. """
  32. def __init__(self, parent, title, message, *, text0='', used_names={},
  33. _htest=False, _utest=False):
  34. """Create modal popup, return when destroyed.
  35. Additional subclass init must be done before this unless
  36. _utest=True is passed to suppress wait_window().
  37. title - string, title of popup dialog
  38. message - string, informational message to display
  39. text0 - initial value for entry
  40. used_names - names already in use
  41. _htest - bool, change box location when running htest
  42. _utest - bool, leave window hidden and not modal
  43. """
  44. self.parent = parent # Needed for Font call.
  45. self.message = message
  46. self.text0 = text0
  47. self.used_names = used_names
  48. Toplevel.__init__(self, parent)
  49. self.withdraw() # Hide while configuring, especially geometry.
  50. self.title(title)
  51. self.transient(parent)
  52. if not _utest: # Otherwise fail when directly run unittest.
  53. self.grab_set()
  54. _setup_dialog(self)
  55. if self._windowingsystem == 'aqua':
  56. self.bind("<Command-.>", self.cancel)
  57. self.bind('<Key-Escape>', self.cancel)
  58. self.protocol("WM_DELETE_WINDOW", self.cancel)
  59. self.bind('<Key-Return>', self.ok)
  60. self.bind("<KP_Enter>", self.ok)
  61. self.create_widgets()
  62. self.update_idletasks() # Need here for winfo_reqwidth below.
  63. self.geometry( # Center dialog over parent (or below htest box).
  64. "+%d+%d" % (
  65. parent.winfo_rootx() +
  66. (parent.winfo_width()/2 - self.winfo_reqwidth()/2),
  67. parent.winfo_rooty() +
  68. ((parent.winfo_height()/2 - self.winfo_reqheight()/2)
  69. if not _htest else 150)
  70. ) )
  71. self.resizable(height=False, width=False)
  72. if not _utest:
  73. self.deiconify() # Unhide now that geometry set.
  74. self.entry.focus_set()
  75. self.wait_window()
  76. def create_widgets(self, ok_text='OK'): # Do not replace.
  77. """Create entry (rows, extras, buttons.
  78. Entry stuff on rows 0-2, spanning cols 0-2.
  79. Buttons on row 99, cols 1, 2.
  80. """
  81. # Bind to self the widgets needed for entry_ok or unittest.
  82. self.frame = frame = Frame(self, padding=10)
  83. frame.grid(column=0, row=0, sticky='news')
  84. frame.grid_columnconfigure(0, weight=1)
  85. entrylabel = Label(frame, anchor='w', justify='left',
  86. text=self.message)
  87. self.entryvar = StringVar(self, self.text0)
  88. self.entry = Entry(frame, width=30, textvariable=self.entryvar)
  89. self.error_font = Font(name='TkCaptionFont',
  90. exists=True, root=self.parent)
  91. self.entry_error = Label(frame, text=' ', foreground='red',
  92. font=self.error_font)
  93. # Display or blank error by setting ['text'] =.
  94. entrylabel.grid(column=0, row=0, columnspan=3, padx=5, sticky=W)
  95. self.entry.grid(column=0, row=1, columnspan=3, padx=5, sticky=W+E,
  96. pady=[10,0])
  97. self.entry_error.grid(column=0, row=2, columnspan=3, padx=5,
  98. sticky=W+E)
  99. self.create_extra()
  100. self.button_ok = Button(
  101. frame, text=ok_text, default='active', command=self.ok)
  102. self.button_cancel = Button(
  103. frame, text='Cancel', command=self.cancel)
  104. self.button_ok.grid(column=1, row=99, padx=5)
  105. self.button_cancel.grid(column=2, row=99, padx=5)
  106. def create_extra(self): pass # Override to add widgets.
  107. def showerror(self, message, widget=None):
  108. #self.bell(displayof=self)
  109. (widget or self.entry_error)['text'] = 'ERROR: ' + message
  110. def entry_ok(self): # Example: usually replace.
  111. "Return non-blank entry or None."
  112. entry = self.entry.get().strip()
  113. if not entry:
  114. self.showerror('blank line.')
  115. return None
  116. return entry
  117. def ok(self, event=None): # Do not replace.
  118. '''If entry is valid, bind it to 'result' and destroy tk widget.
  119. Otherwise leave dialog open for user to correct entry or cancel.
  120. '''
  121. self.entry_error['text'] = ''
  122. entry = self.entry_ok()
  123. if entry is not None:
  124. self.result = entry
  125. self.destroy()
  126. else:
  127. # [Ok] moves focus. (<Return> does not.) Move it back.
  128. self.entry.focus_set()
  129. def cancel(self, event=None): # Do not replace.
  130. "Set dialog result to None and destroy tk widget."
  131. self.result = None
  132. self.destroy()
  133. def destroy(self):
  134. self.grab_release()
  135. super().destroy()
  136. class SectionName(Query):
  137. "Get a name for a config file section name."
  138. # Used in ConfigDialog.GetNewKeysName, .GetNewThemeName (837)
  139. def __init__(self, parent, title, message, used_names,
  140. *, _htest=False, _utest=False):
  141. super().__init__(parent, title, message, used_names=used_names,
  142. _htest=_htest, _utest=_utest)
  143. def entry_ok(self):
  144. "Return sensible ConfigParser section name or None."
  145. name = self.entry.get().strip()
  146. if not name:
  147. self.showerror('no name specified.')
  148. return None
  149. elif len(name)>30:
  150. self.showerror('name is longer than 30 characters.')
  151. return None
  152. elif name in self.used_names:
  153. self.showerror('name is already in use.')
  154. return None
  155. return name
  156. class ModuleName(Query):
  157. "Get a module name for Open Module menu entry."
  158. # Used in open_module (editor.EditorWindow until move to iobinding).
  159. def __init__(self, parent, title, message, text0,
  160. *, _htest=False, _utest=False):
  161. super().__init__(parent, title, message, text0=text0,
  162. _htest=_htest, _utest=_utest)
  163. def entry_ok(self):
  164. "Return entered module name as file path or None."
  165. name = self.entry.get().strip()
  166. if not name:
  167. self.showerror('no name specified.')
  168. return None
  169. # XXX Ought to insert current file's directory in front of path.
  170. try:
  171. spec = importlib.util.find_spec(name)
  172. except (ValueError, ImportError) as msg:
  173. self.showerror(str(msg))
  174. return None
  175. if spec is None:
  176. self.showerror("module not found.")
  177. return None
  178. if not isinstance(spec.loader, importlib.abc.SourceLoader):
  179. self.showerror("not a source-based module.")
  180. return None
  181. try:
  182. file_path = spec.loader.get_filename(name)
  183. except AttributeError:
  184. self.showerror("loader does not support get_filename.")
  185. return None
  186. except ImportError:
  187. # Some special modules require this (e.g. os.path)
  188. try:
  189. file_path = spec.loader.get_filename()
  190. except TypeError:
  191. self.showerror("loader failed to get filename.")
  192. return None
  193. return file_path
  194. class Goto(Query):
  195. "Get a positive line number for editor Go To Line."
  196. # Used in editor.EditorWindow.goto_line_event.
  197. def entry_ok(self):
  198. try:
  199. lineno = int(self.entry.get())
  200. except ValueError:
  201. self.showerror('not a base 10 integer.')
  202. return None
  203. if lineno <= 0:
  204. self.showerror('not a positive integer.')
  205. return None
  206. return lineno
  207. class HelpSource(Query):
  208. "Get menu name and help source for Help menu."
  209. # Used in ConfigDialog.HelpListItemAdd/Edit, (941/9)
  210. def __init__(self, parent, title, *, menuitem='', filepath='',
  211. used_names={}, _htest=False, _utest=False):
  212. """Get menu entry and url/local file for Additional Help.
  213. User enters a name for the Help resource and a web url or file
  214. name. The user can browse for the file.
  215. """
  216. self.filepath = filepath
  217. message = 'Name for item on Help menu:'
  218. super().__init__(
  219. parent, title, message, text0=menuitem,
  220. used_names=used_names, _htest=_htest, _utest=_utest)
  221. def create_extra(self):
  222. "Add path widjets to rows 10-12."
  223. frame = self.frame
  224. pathlabel = Label(frame, anchor='w', justify='left',
  225. text='Help File Path: Enter URL or browse for file')
  226. self.pathvar = StringVar(self, self.filepath)
  227. self.path = Entry(frame, textvariable=self.pathvar, width=40)
  228. browse = Button(frame, text='Browse', width=8,
  229. command=self.browse_file)
  230. self.path_error = Label(frame, text=' ', foreground='red',
  231. font=self.error_font)
  232. pathlabel.grid(column=0, row=10, columnspan=3, padx=5, pady=[10,0],
  233. sticky=W)
  234. self.path.grid(column=0, row=11, columnspan=2, padx=5, sticky=W+E,
  235. pady=[10,0])
  236. browse.grid(column=2, row=11, padx=5, sticky=W+S)
  237. self.path_error.grid(column=0, row=12, columnspan=3, padx=5,
  238. sticky=W+E)
  239. def askfilename(self, filetypes, initdir, initfile): # htest #
  240. # Extracted from browse_file so can mock for unittests.
  241. # Cannot unittest as cannot simulate button clicks.
  242. # Test by running htest, such as by running this file.
  243. return filedialog.Open(parent=self, filetypes=filetypes)\
  244. .show(initialdir=initdir, initialfile=initfile)
  245. def browse_file(self):
  246. filetypes = [
  247. ("HTML Files", "*.htm *.html", "TEXT"),
  248. ("PDF Files", "*.pdf", "TEXT"),
  249. ("Windows Help Files", "*.chm"),
  250. ("Text Files", "*.txt", "TEXT"),
  251. ("All Files", "*")]
  252. path = self.pathvar.get()
  253. if path:
  254. dir, base = os.path.split(path)
  255. else:
  256. base = None
  257. if platform[:3] == 'win':
  258. dir = os.path.join(os.path.dirname(executable), 'Doc')
  259. if not os.path.isdir(dir):
  260. dir = os.getcwd()
  261. else:
  262. dir = os.getcwd()
  263. file = self.askfilename(filetypes, dir, base)
  264. if file:
  265. self.pathvar.set(file)
  266. item_ok = SectionName.entry_ok # localize for test override
  267. def path_ok(self):
  268. "Simple validity check for menu file path"
  269. path = self.path.get().strip()
  270. if not path: #no path specified
  271. self.showerror('no help file path specified.', self.path_error)
  272. return None
  273. elif not path.startswith(('www.', 'http')):
  274. if path[:5] == 'file:':
  275. path = path[5:]
  276. if not os.path.exists(path):
  277. self.showerror('help file path does not exist.',
  278. self.path_error)
  279. return None
  280. if platform == 'darwin': # for Mac Safari
  281. path = "file://" + path
  282. return path
  283. def entry_ok(self):
  284. "Return apparently valid (name, path) or None"
  285. self.path_error['text'] = ''
  286. name = self.item_ok()
  287. path = self.path_ok()
  288. return None if name is None or path is None else (name, path)
  289. class CustomRun(Query):
  290. """Get settings for custom run of module.
  291. 1. Command line arguments to extend sys.argv.
  292. 2. Whether to restart Shell or not.
  293. """
  294. # Used in runscript.run_custom_event
  295. def __init__(self, parent, title, *, cli_args=[],
  296. _htest=False, _utest=False):
  297. """cli_args is a list of strings.
  298. The list is assigned to the default Entry StringVar.
  299. The strings are displayed joined by ' ' for display.
  300. """
  301. message = 'Command Line Arguments for sys.argv:'
  302. super().__init__(
  303. parent, title, message, text0=cli_args,
  304. _htest=_htest, _utest=_utest)
  305. def create_extra(self):
  306. "Add run mode on rows 10-12."
  307. frame = self.frame
  308. self.restartvar = BooleanVar(self, value=True)
  309. restart = Checkbutton(frame, variable=self.restartvar, onvalue=True,
  310. offvalue=False, text='Restart shell')
  311. self.args_error = Label(frame, text=' ', foreground='red',
  312. font=self.error_font)
  313. restart.grid(column=0, row=10, columnspan=3, padx=5, sticky='w')
  314. self.args_error.grid(column=0, row=12, columnspan=3, padx=5,
  315. sticky='we')
  316. def cli_args_ok(self):
  317. "Validity check and parsing for command line arguments."
  318. cli_string = self.entry.get().strip()
  319. try:
  320. cli_args = shlex.split(cli_string, posix=True)
  321. except ValueError as err:
  322. self.showerror(str(err))
  323. return None
  324. return cli_args
  325. def entry_ok(self):
  326. "Return apparently valid (cli_args, restart) or None."
  327. cli_args = self.cli_args_ok()
  328. restart = self.restartvar.get()
  329. return None if cli_args is None else (cli_args, restart)
  330. if __name__ == '__main__':
  331. from unittest import main
  332. main('idlelib.idle_test.test_query', verbosity=2, exit=False)
  333. from idlelib.idle_test.htest import run
  334. run(Query, HelpSource, CustomRun)