ToolShell.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. # ##########################################################
  2. # FlatCAM: 2D Post-processing for Manufacturing #
  3. # http://flatcam.org #
  4. # Author: Juan Pablo Caram (c) #
  5. # Date: 2/5/2014 #
  6. # MIT Licence #
  7. # ##########################################################
  8. from PyQt5.QtCore import Qt
  9. from PyQt5.QtGui import QTextCursor
  10. from PyQt5.QtWidgets import QVBoxLayout, QWidget
  11. from flatcamGUI.GUIElements import _BrowserTextEdit, _ExpandableTextEdit
  12. import html
  13. import sys
  14. import traceback
  15. import tkinter as tk
  16. import tclCommands
  17. import gettext
  18. import FlatCAMTranslation as fcTranslate
  19. import builtins
  20. fcTranslate.apply_language('strings')
  21. if '_' not in builtins.__dict__:
  22. _ = gettext.gettext
  23. class TermWidget(QWidget):
  24. """
  25. Widget which represents terminal. It only displays text and allows to enter text.
  26. All high level logic should be implemented by client classes
  27. User pressed Enter. Client class should decide, if command must be executed or user may continue edit it
  28. """
  29. def __init__(self, version, app, *args):
  30. QWidget.__init__(self, *args)
  31. self._browser = _BrowserTextEdit(version=version, app=app)
  32. self._browser.setStyleSheet("font: 9pt \"Courier\";")
  33. self._browser.setReadOnly(True)
  34. self._browser.document().setDefaultStyleSheet(
  35. self._browser.document().defaultStyleSheet() +
  36. "span {white-space:pre;}")
  37. self._edit = _ExpandableTextEdit(self, self)
  38. self._edit.historyNext.connect(self._on_history_next)
  39. self._edit.historyPrev.connect(self._on_history_prev)
  40. self._edit.setFocus()
  41. self.setFocusProxy(self._edit)
  42. layout = QVBoxLayout(self)
  43. layout.setSpacing(0)
  44. layout.setContentsMargins(0, 0, 0, 0)
  45. layout.addWidget(self._browser)
  46. layout.addWidget(self._edit)
  47. self._history = [''] # current empty line
  48. self._historyIndex = 0
  49. def open_processing(self, detail=None):
  50. """
  51. Open processing and disable using shell commands again until all commands are finished
  52. :param detail: text detail about what is currently called from TCL to python
  53. :return: None
  54. """
  55. self._edit.setTextColor(Qt.white)
  56. self._edit.setTextBackgroundColor(Qt.darkGreen)
  57. if detail is None:
  58. self._edit.setPlainText(_("...processing..."))
  59. else:
  60. self._edit.setPlainText('%s [%s]' % (_("...processing..."), detail))
  61. self._edit.setDisabled(True)
  62. self._edit.setFocus()
  63. def close_processing(self):
  64. """
  65. Close processing and enable using shell commands again
  66. :return:
  67. """
  68. self._edit.setTextColor(Qt.black)
  69. self._edit.setTextBackgroundColor(Qt.white)
  70. self._edit.setPlainText('')
  71. self._edit.setDisabled(False)
  72. self._edit.setFocus()
  73. def _append_to_browser(self, style, text):
  74. """
  75. Convert text to HTML for inserting it to browser
  76. """
  77. assert style in ('in', 'out', 'err', 'warning', 'success', 'selected', 'raw')
  78. if style != 'raw':
  79. text = html.escape(text)
  80. text = text.replace('\n', '<br/>')
  81. else:
  82. text = text.replace('\n', '<br>')
  83. text = text.replace('\t', '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;')
  84. idx = text.find(']')
  85. mtype = text[:idx+1].upper()
  86. mtype = mtype.replace('_NOTCL', '')
  87. body = text[idx+1:]
  88. if style == 'in':
  89. text = '<span style="font-weight: bold;">%s</span>' % text
  90. elif style == 'err':
  91. text = '<span style="font-weight: bold; color: red;">%s</span>'\
  92. '<span style="font-weight: bold;">%s</span>'\
  93. % (mtype, body)
  94. elif style == 'warning':
  95. # text = '<span style="font-weight: bold; color: #f4b642;">%s</span>' % text
  96. text = '<span style="font-weight: bold; color: #f4b642;">%s</span>' \
  97. '<span style="font-weight: bold;">%s</span>' \
  98. % (mtype, body)
  99. elif style == 'success':
  100. # text = '<span style="font-weight: bold; color: #15b300;">%s</span>' % text
  101. text = '<span style="font-weight: bold; color: #15b300;">%s</span>' \
  102. '<span style="font-weight: bold;">%s</span>' \
  103. % (mtype, body)
  104. elif style == 'selected':
  105. text = ''
  106. elif style == 'raw':
  107. text = text
  108. else:
  109. # without span <br/> is ignored!!!
  110. text = '<span>%s</span>' % text
  111. scrollbar = self._browser.verticalScrollBar()
  112. old_value = scrollbar.value()
  113. # scrollattheend = old_value == scrollbar.maximum()
  114. self._browser.moveCursor(QTextCursor.End)
  115. self._browser.insertHtml(text)
  116. """TODO When user enters second line to the input, and input is resized, scrollbar changes its position
  117. and stops moving. As quick fix of this problem, now we always scroll down when add new text.
  118. To fix it correctly, scroll to the bottom, if before input has been resized,
  119. scrollbar was in the bottom, and remove next line
  120. """
  121. scrollattheend = True
  122. if scrollattheend:
  123. scrollbar.setValue(scrollbar.maximum())
  124. else:
  125. scrollbar.setValue(old_value)
  126. def exec_current_command(self):
  127. """
  128. Save current command in the history. Append it to the log. Clear edit line
  129. Re-implement in the child classes to actually execute command
  130. """
  131. text = str(self._edit.toPlainText())
  132. # in Windows replace all backslash symbols '\' with '\\' slash because Windows paths are made with backslash
  133. # and in Python single slash is the escape symbol
  134. if sys.platform == 'win32':
  135. text = text.replace('\\', '\\\\')
  136. self._append_to_browser('in', '> ' + text + '\n')
  137. if len(self._history) < 2 or self._history[-2] != text: # don't insert duplicating items
  138. try:
  139. if text[-1] == '\n':
  140. self._history.insert(-1, text[:-1])
  141. else:
  142. self._history.insert(-1, text)
  143. except IndexError:
  144. return
  145. self._historyIndex = len(self._history) - 1
  146. self._history[-1] = ''
  147. self._edit.clear()
  148. if not text[-1] == '\n':
  149. text += '\n'
  150. self.child_exec_command(text)
  151. def child_exec_command(self, text):
  152. """
  153. Re-implement in the child classes
  154. """
  155. pass
  156. def add_line_break_to_input(self):
  157. self._edit.textCursor().insertText('\n')
  158. def append_output(self, text):
  159. """
  160. Append text to output widget
  161. """
  162. self._append_to_browser('out', text)
  163. def append_raw(self, text):
  164. """
  165. Append text to output widget as it is
  166. """
  167. self._append_to_browser('raw', text)
  168. def append_success(self, text):
  169. """Append text to output widget
  170. """
  171. self._append_to_browser('success', text)
  172. def append_selected(self, text):
  173. """Append text to output widget
  174. """
  175. self._append_to_browser('selected', text)
  176. def append_warning(self, text):
  177. """Append text to output widget
  178. """
  179. self._append_to_browser('warning', text)
  180. def append_error(self, text):
  181. """Append error text to output widget. Text is drawn with red background
  182. """
  183. self._append_to_browser('err', text)
  184. def is_command_complete(self, text):
  185. """
  186. Executed by _ExpandableTextEdit. Reimplement this function in the child classes.
  187. """
  188. return True
  189. def browser(self):
  190. return self._browser
  191. def _on_history_next(self):
  192. """
  193. Down pressed, show next item from the history
  194. """
  195. if (self._historyIndex + 1) < len(self._history):
  196. self._historyIndex += 1
  197. self._edit.setPlainText(self._history[self._historyIndex])
  198. self._edit.moveCursor(QTextCursor.End)
  199. def _on_history_prev(self):
  200. """
  201. Up pressed, show previous item from the history
  202. """
  203. if self._historyIndex > 0:
  204. if self._historyIndex == (len(self._history) - 1):
  205. self._history[-1] = self._edit.toPlainText()
  206. self._historyIndex -= 1
  207. self._edit.setPlainText(self._history[self._historyIndex])
  208. self._edit.moveCursor(QTextCursor.End)
  209. class FCShell(TermWidget):
  210. def __init__(self, app, version, *args):
  211. """
  212. Initialize the TCL Shell. A dock widget that holds the GUI interface to the FlatCAM command line.
  213. :param app: When instantiated the sysShell will be actually the FlatCAMApp.App() class
  214. :param version: FlatCAM version string
  215. :param args: Parameters passed to the TermWidget parent class
  216. """
  217. TermWidget.__init__(self, version, *args, app=app)
  218. self.app = app
  219. self.tcl_commands_storage = {}
  220. if hasattr(self, 'tcl') and self.tcl is not None:
  221. # self.tcl = None
  222. # new object cannot be used here as it will not remember values created for next passes,
  223. # because tcl was executed in old instance of TCL
  224. pass
  225. else:
  226. self.tcl = tk.Tcl()
  227. self.setup_shell()
  228. self._edit.set_model_data(self.app.myKeywords)
  229. self.setWindowIcon(self.app.ui.app_icon)
  230. self.setWindowTitle("FlatCAM Shell")
  231. self.resize(*self.app.defaults["global_shell_shape"])
  232. self._append_to_browser('in', "FlatCAM %s - " % version)
  233. self.append_output('%s\n\n' % _("Type >help< to get started"))
  234. def setup_shell(self):
  235. """
  236. Creates shell functions. Runs once at startup.
  237. :return: None
  238. """
  239. '''
  240. How to implement TCL shell commands:
  241. All parameters passed to command should be possible to set as None and test it afterwards.
  242. This is because we need to see error caused in tcl,
  243. if None value as default parameter is not allowed TCL will return empty error.
  244. Use:
  245. def mycommand(name=None,...):
  246. Test it like this:
  247. if name is None:
  248. self.raise_tcl_error('Argument name is missing.')
  249. When error occurred, always use raise_tcl_error, never return "some text" on error,
  250. otherwise we will miss it and processing will silently continue.
  251. Method raise_tcl_error pass error into TCL interpreter, then raise python exception,
  252. which is caught in exec_command and displayed in TCL shell console with red background.
  253. Error in console is displayed with TCL trace.
  254. This behavior works only within main thread,
  255. errors with promissed tasks can be catched and detected only with log.
  256. TODO: this problem have to be addressed somehow, maybe rewrite promissing to be blocking somehow for
  257. TCL shell.
  258. Kamil's comment: I will rewrite existing TCL commands from time to time to follow this rules.
  259. '''
  260. # Import/overwrite tcl commands as objects of TclCommand descendants
  261. # This modifies the variable 'self.tcl_commands_storage'.
  262. tclCommands.register_all_commands(self.app, self.tcl_commands_storage)
  263. # Add commands to the tcl interpreter
  264. for cmd in self.tcl_commands_storage:
  265. self.tcl.createcommand(cmd, self.tcl_commands_storage[cmd]['fcn'])
  266. # Make the tcl puts function return instead of print to stdout
  267. self.tcl.eval('''
  268. rename puts original_puts
  269. proc puts {args} {
  270. if {[llength $args] == 1} {
  271. return "[lindex $args 0]"
  272. } else {
  273. eval original_puts $args
  274. }
  275. }
  276. ''')
  277. def is_command_complete(self, text):
  278. def skipQuotes(txt):
  279. quote = txt[0]
  280. text_val = txt[1:]
  281. endIndex = str(text_val).index(quote)
  282. return text[endIndex:]
  283. while text:
  284. if text[0] in ('"', "'"):
  285. try:
  286. text = skipQuotes(text)
  287. except ValueError:
  288. return False
  289. text = text[1:]
  290. return True
  291. def child_exec_command(self, text):
  292. self.exec_command(text)
  293. def exec_command(self, text, no_echo=False):
  294. """
  295. Handles input from the shell. See FlatCAMApp.setup_shell for shell commands.
  296. Also handles execution in separated threads
  297. :param text: FlatCAM TclCommand with parameters
  298. :param no_echo: If True it will not try to print to the Shell because most likely the shell is hidden and it
  299. will create crashes of the _Expandable_Edit widget
  300. :return: output if there was any
  301. """
  302. self.app.report_usage('exec_command')
  303. return self.exec_command_test(text, False, no_echo=no_echo)
  304. def exec_command_test(self, text, reraise=True, no_echo=False):
  305. """
  306. Same as exec_command(...) with additional control over exceptions.
  307. Handles input from the shell. See FlatCAMApp.setup_shell for shell commands.
  308. :param text: Input command
  309. :param reraise: Re-raise TclError exceptions in Python (mostly for unittests).
  310. :param no_echo: If True it will not try to print to the Shell because most likely the shell is hidden and it
  311. will create crashes of the _Expandable_Edit widget
  312. :return: Output from the command
  313. """
  314. tcl_command_string = str(text)
  315. try:
  316. if no_echo is False:
  317. self.open_processing() # Disables input box.
  318. result = self.tcl.eval(str(tcl_command_string))
  319. if result != 'None' and no_echo is False:
  320. self.append_output(result + '\n')
  321. except tk.TclError as e:
  322. # This will display more precise answer if something in TCL shell fails
  323. result = self.tcl.eval("set errorInfo")
  324. self.app.log.error("Exec command Exception: %s" % (result + '\n'))
  325. if no_echo is False:
  326. self.append_error('ERROR: ' + result + '\n')
  327. # Show error in console and just return or in test raise exception
  328. if reraise:
  329. raise e
  330. finally:
  331. if no_echo is False:
  332. self.close_processing()
  333. pass
  334. return result
  335. def raise_tcl_unknown_error(self, unknownException):
  336. """
  337. Raise exception if is different type than TclErrorException
  338. this is here mainly to show unknown errors inside TCL shell console.
  339. :param unknownException:
  340. :return:
  341. """
  342. if not isinstance(unknownException, self.TclErrorException):
  343. self.raise_tcl_error("Unknown error: %s" % str(unknownException))
  344. else:
  345. raise unknownException
  346. def display_tcl_error(self, error, error_info=None):
  347. """
  348. Escape bracket [ with '\' otherwise there is error
  349. "ERROR: missing close-bracket" instead of real error
  350. :param error: it may be text or exception
  351. :param error_info: Some informations about the error
  352. :return: None
  353. """
  354. if isinstance(error, Exception):
  355. exc_type, exc_value, exc_traceback = error_info
  356. if not isinstance(error, self.TclErrorException):
  357. show_trace = 1
  358. else:
  359. show_trace = int(self.app.defaults['global_verbose_error_level'])
  360. if show_trace > 0:
  361. trc = traceback.format_list(traceback.extract_tb(exc_traceback))
  362. trc_formated = []
  363. for a in reversed(trc):
  364. trc_formated.append(a.replace(" ", " > ").replace("\n", ""))
  365. text = "%s\nPython traceback: %s\n%s" % (exc_value, exc_type, "\n".join(trc_formated))
  366. else:
  367. text = "%s" % error
  368. else:
  369. text = error
  370. text = text.replace('[', '\\[').replace('"', '\\"')
  371. self.tcl.eval('return -code error "%s"' % text)
  372. def raise_tcl_error(self, text):
  373. """
  374. This method pass exception from python into TCL as error, so we get stacktrace and reason
  375. :param text: text of error
  376. :return: raise exception
  377. """
  378. self.display_tcl_error(text)
  379. raise self.TclErrorException(text)
  380. class TclErrorException(Exception):
  381. """
  382. this exception is defined here, to be able catch it if we successfully handle all errors from shell command
  383. """
  384. pass
  385. # """
  386. # Code below is unsused. Saved for later.
  387. # """
  388. # parts = re.findall(r'([\w\\:\.]+|".*?")+', text)
  389. # parts = [p.replace('\n', '').replace('"', '') for p in parts]
  390. # self.log.debug(parts)
  391. # try:
  392. # if parts[0] not in commands:
  393. # self.shell.append_error("Unknown command\n")
  394. # return
  395. #
  396. # #import inspect
  397. # #inspect.getargspec(someMethod)
  398. # if (type(commands[parts[0]]["params"]) is not list and len(parts)-1 != commands[parts[0]]["params"]) or \
  399. # (type(commands[parts[0]]["params"]) is list and len(parts)-1 not in commands[parts[0]]["params"]):
  400. # self.shell.append_error(
  401. # "Command %s takes %d arguments. %d given.\n" %
  402. # (parts[0], commands[parts[0]]["params"], len(parts)-1)
  403. # )
  404. # return
  405. #
  406. # cmdfcn = commands[parts[0]]["fcn"]
  407. # cmdconv = commands[parts[0]]["converters"]
  408. # if len(parts) - 1 > 0:
  409. # retval = cmdfcn(*[cmdconv[i](parts[i + 1]) for i in range(len(parts)-1)])
  410. # else:
  411. # retval = cmdfcn()
  412. # retfcn = commands[parts[0]]["retfcn"]
  413. # if retval and retfcn(retval):
  414. # self.shell.append_output(retfcn(retval) + "\n")
  415. #
  416. # except Exception as e:
  417. # #self.shell.append_error(''.join(traceback.format_exc()))
  418. # #self.shell.append_error("?\n")
  419. # self.shell.append_error(str(e) + "\n")