pyedit.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  1. """GRASS GIS Simple Python Editor
  2. Copyright (C) 2016 by the GRASS Development Team
  3. This program is free software under the GNU General Public
  4. License (>=v2). Read the file COPYING that comes with GRASS GIS
  5. for details.
  6. :authors: Vaclav Petras
  7. :authors: Martin Landa
  8. """
  9. import sys
  10. import os
  11. import stat
  12. try:
  13. from StringIO import StringIO
  14. except ImportError:
  15. from io import StringIO
  16. import time
  17. import wx
  18. import grass.script as gscript
  19. from grass.script.utils import try_remove
  20. # needed just for testing
  21. if __name__ == '__main__':
  22. from grass.script.setup import set_gui_path
  23. set_gui_path()
  24. from core.gcmd import GError
  25. from gui_core.pystc import PyStc
  26. from core import globalvar
  27. from core.menutree import MenuTreeModelBuilder
  28. from gui_core.menu import Menu
  29. from gui_core.toolbars import BaseToolbar, BaseIcons
  30. from icons.icon import MetaIcon
  31. from core.debug import Debug
  32. # TODO: add validation: call/import pep8 (error message if not available)
  33. # TODO: run with parameters (alternatively, just use console or GUI)
  34. # TODO: add more examples (better separate file)
  35. # TODO: add test for templates and examples
  36. # TODO: add pep8 test for templates and examples
  37. # TODO: add snippets?
  38. def script_template():
  39. """The most simple script which runs and gives something"""
  40. return r"""#!/usr/bin/env python3
  41. import grass.script as gscript
  42. def main():
  43. gscript.run_command('g.region', flags='p')
  44. if __name__ == '__main__':
  45. main()
  46. """
  47. def module_template():
  48. """Template from which to start writing GRASS module"""
  49. import getpass
  50. author = getpass.getuser()
  51. properties = {}
  52. properties['name'] = 'module name'
  53. properties['author'] = author
  54. properties['description'] = 'Module description'
  55. output = StringIO()
  56. # header
  57. output.write(
  58. r"""#!/usr/bin/env python3
  59. #
  60. #%s
  61. #
  62. # MODULE: %s
  63. #
  64. # AUTHOR(S): %s
  65. #
  66. # PURPOSE: %s
  67. #
  68. # DATE: %s
  69. #
  70. #%s
  71. """ % ('#' * 72,
  72. properties['name'],
  73. properties['author'],
  74. '\n# '.join(properties['description'].splitlines()),
  75. time.asctime(),
  76. '#' * 72))
  77. # UI
  78. output.write(
  79. r"""
  80. #%%module
  81. #%% description: %s
  82. #%%end
  83. """ % (' '.join(properties['description'].splitlines())))
  84. # import modules
  85. output.write(
  86. r"""
  87. import sys
  88. import os
  89. import atexit
  90. import grass.script as gscript
  91. """)
  92. # cleanup()
  93. output.write(
  94. r"""
  95. RAST_REMOVE = []
  96. def cleanup():
  97. """)
  98. output.write(
  99. r""" gscript.run_command('g.remove', flags='f', type='raster',
  100. name=RAST_REMOVE)
  101. """)
  102. output.write("\ndef main():\n")
  103. output.write(
  104. r""" options, flags = gscript.parser()
  105. gscript.run_command('g.remove', flags='f', type='raster',
  106. name=RAST_REMOVE)
  107. """)
  108. output.write("\n return 0\n")
  109. output.write(
  110. r"""
  111. if __name__ == "__main__":
  112. atexit.register(cleanup)
  113. sys.exit(main())
  114. """)
  115. return output.getvalue()
  116. def script_example():
  117. """Example of a simple script"""
  118. return r"""#!/usr/bin/env python3
  119. import grass.script as gscript
  120. def main():
  121. input_raster = 'elevation'
  122. output_raster = 'high_areas'
  123. stats = gscript.parse_command('r.univar', map='elevation', flags='g')
  124. raster_mean = float(stats['mean'])
  125. raster_stddev = float(stats['stddev'])
  126. raster_high = raster_mean + raster_stddev
  127. gscript.mapcalc('{r} = {a} > {m}'.format(r=output_raster, a=input_raster,
  128. m=raster_high))
  129. if __name__ == "__main__":
  130. main()
  131. """
  132. def module_example():
  133. """Example of a GRASS module"""
  134. return r"""#!/usr/bin/env python3
  135. #%module
  136. #% description: Adds the values of two rasters (A + B)
  137. #% keyword: raster
  138. #% keyword: algebra
  139. #% keyword: sum
  140. #%end
  141. #%option G_OPT_R_INPUT
  142. #% key: araster
  143. #% description: Name of input raster A in an expression A + B
  144. #%end
  145. #%option G_OPT_R_INPUT
  146. #% key: braster
  147. #% description: Name of input raster B in an expression A + B
  148. #%end
  149. #%option G_OPT_R_OUTPUT
  150. #%end
  151. import sys
  152. import grass.script as gscript
  153. def main():
  154. options, flags = gscript.parser()
  155. araster = options['araster']
  156. braster = options['braster']
  157. output = options['output']
  158. gscript.mapcalc('{r} = {a} + {b}'.format(r=output, a=araster, b=braster))
  159. return 0
  160. if __name__ == "__main__":
  161. sys.exit(main())
  162. """
  163. def module_error_handling_example():
  164. """Example of a GRASS module"""
  165. return r"""#!/usr/bin/env python3
  166. #%module
  167. #% description: Selects values from raster above value of mean plus standard deviation
  168. #% keyword: raster
  169. #% keyword: select
  170. #% keyword: standard deviation
  171. #%end
  172. #%option G_OPT_R_INPUT
  173. #%end
  174. #%option G_OPT_R_OUTPUT
  175. #%end
  176. import sys
  177. import grass.script as gscript
  178. from grass.exceptions import CalledModuleError
  179. def main():
  180. options, flags = gscript.parser()
  181. input_raster = options['input']
  182. output_raster = options['output']
  183. try:
  184. stats = gscript.parse_command('r.univar', map=input_raster, flags='g')
  185. except CalledModuleError as e:
  186. gscript.fatal('{0}'.format(e))
  187. raster_mean = float(stats['mean'])
  188. raster_stddev = float(stats['stddev'])
  189. raster_high = raster_mean + raster_stddev
  190. gscript.mapcalc('{r} = {i} > {v}'.format(r=output_raster, i=input_raster,
  191. v=raster_high))
  192. return 0
  193. if __name__ == "__main__":
  194. sys.exit(main())
  195. """
  196. def open_url(url):
  197. import webbrowser
  198. webbrowser.open(url)
  199. class PyEditController(object):
  200. # using the naming GUI convention, change for controller?
  201. # pylint: disable=invalid-name
  202. def __init__(self, panel, guiparent, giface):
  203. """Simple editor, this class could be a pure controller"""
  204. self.guiparent = guiparent
  205. self.giface = giface
  206. self.body = panel
  207. self.filename = None
  208. self.tempfile = None # bool, make them strings for better code
  209. self.overwrite = False
  210. self.parameters = None
  211. def OnRun(self, event):
  212. """Run Python script"""
  213. if not self.filename:
  214. self.filename = gscript.tempfile() + '.py'
  215. self.tempfile = True
  216. try:
  217. fd = open(self.filename, "w")
  218. fd.write(self.body.GetText())
  219. except IOError as e:
  220. GError(_("Unable to launch Python script. %s") % e,
  221. parent=self.guiparent)
  222. return
  223. finally:
  224. fd.close()
  225. mode = stat.S_IMODE(os.lstat(self.filename)[stat.ST_MODE])
  226. os.chmod(self.filename, mode | stat.S_IXUSR)
  227. else:
  228. # always save automatically before running
  229. fd = open(self.filename, "w")
  230. try:
  231. fd.write(self.body.GetText())
  232. finally:
  233. fd.close()
  234. # set executable file
  235. # (not sure if needed every time but useful for opened files)
  236. os.chmod(self.filename, stat.S_IRWXU | stat.S_IWUSR)
  237. # run in console as other modules, avoid Python shell which
  238. # carries variables over to the next execution
  239. env = os.environ.copy()
  240. if self.overwrite:
  241. env['GRASS_OVERWRITE'] = '1'
  242. cmd = [fd.name]
  243. if self.parameters:
  244. cmd.extend(self.parameters)
  245. self.giface.RunCmd(cmd, env=env)
  246. def SaveAs(self):
  247. """Save python script to file"""
  248. if self.tempfile:
  249. try_remove(self.filename)
  250. self.tempfile = False
  251. filename = None
  252. dlg = wx.FileDialog(parent=self.guiparent,
  253. message=_("Choose file to save"),
  254. defaultDir=os.getcwd(),
  255. wildcard=_("Python script (*.py)|*.py"),
  256. style=wx.FD_SAVE)
  257. if dlg.ShowModal() == wx.ID_OK:
  258. filename = dlg.GetPath()
  259. if not filename:
  260. return
  261. # check for extension
  262. if filename[-3:] != ".py":
  263. filename += ".py"
  264. if os.path.exists(filename):
  265. dlg = wx.MessageDialog(
  266. parent=self.guiparent,
  267. message=_("File <%s> already exists. "
  268. "Do you want to overwrite this file?") % filename,
  269. caption=_("Save file"),
  270. style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION)
  271. if dlg.ShowModal() == wx.ID_NO:
  272. dlg.Destroy()
  273. return
  274. dlg.Destroy()
  275. self.filename = filename
  276. self.tempfile = False
  277. self.Save()
  278. def Save(self):
  279. """Save current content to a file and set executable permissions"""
  280. assert self.filename
  281. fd = open(self.filename, "w")
  282. try:
  283. fd.write(self.body.GetText())
  284. finally:
  285. fd.close()
  286. # executable file
  287. os.chmod(self.filename, stat.S_IRWXU | stat.S_IWUSR)
  288. def OnSave(self, event):
  289. """Save python script to file
  290. Just save if file already specified, save as action otherwise.
  291. """
  292. if self.filename and not self.tempfile:
  293. self.Save()
  294. else:
  295. self.SaveAs()
  296. def IsModified(self):
  297. """Check if python script has been modified"""
  298. return self.body.modified
  299. def Open(self):
  300. """Ask for a filename and load its content"""
  301. filename = ''
  302. dlg = wx.FileDialog(parent=self.guiparent,
  303. message=_("Open file"),
  304. defaultDir=os.getcwd(),
  305. wildcard=_("Python script (*.py)|*.py"),
  306. style=wx.FD_OPEN)
  307. if dlg.ShowModal() == wx.ID_OK:
  308. filename = dlg.GetPath()
  309. if not filename:
  310. return
  311. fd = open(filename, "r")
  312. try:
  313. self.body.SetText(fd.read())
  314. finally:
  315. fd.close()
  316. self.filename = filename
  317. self.tempfile = False
  318. def OnOpen(self, event):
  319. """Handle open event but ask about replacing content first"""
  320. if self.CanReplaceContent('file'):
  321. self.Open()
  322. def IsEmpty(self):
  323. """Check if python script is empty"""
  324. return len(self.body.GetText()) == 0
  325. def IsContentValuable(self):
  326. """Check if content of the editor is valuable to user
  327. Used for example to check if content should be saved before closing.
  328. The content is not valuable for example if it already saved in a file.
  329. """
  330. Debug.msg(2, "pyedit IsContentValuable? empty=%s, modified=%s" % (
  331. self.IsEmpty(), self.IsModified()))
  332. return not self.IsEmpty() and self.IsModified()
  333. def SetScriptTemplate(self, event):
  334. if self.CanReplaceContent('template'):
  335. self.body.SetText(script_template())
  336. def SetModuleTemplate(self, event):
  337. if self.CanReplaceContent('template'):
  338. self.body.SetText(module_template())
  339. def SetScriptExample(self, event):
  340. if self.CanReplaceContent('example'):
  341. self.body.SetText(script_example())
  342. def SetModuleExample(self, event):
  343. if self.CanReplaceContent('example'):
  344. self.body.SetText(module_example())
  345. def SetModuleErrorHandlingExample(self, event):
  346. if self.CanReplaceContent('example'):
  347. self.body.SetText(module_error_handling_example())
  348. def CanReplaceContent(self, by_message):
  349. """Check with user if we can replace content by something else
  350. Asks user if replacement is OK depending on the state of the editor.
  351. Use before replacing all editor content by some other text.
  352. :param by_message: message used to ask user if it is OK to replace
  353. the content with something else; special values are 'template',
  354. 'example' and 'file' which will use predefined messages, otherwise
  355. a translatable, user visible string should be used.
  356. """
  357. if by_message == 'template':
  358. message = _("Replace the content by the template?")
  359. elif by_message == 'example':
  360. message = _("Replace the content by the example?")
  361. elif by_message == 'file':
  362. message = _("Replace the current content by the file content?")
  363. else:
  364. message = by_message
  365. if self.IsContentValuable():
  366. dlg = wx.MessageDialog(
  367. parent=self.guiparent, message=message,
  368. caption=_("Replace content"),
  369. style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION)
  370. if dlg.ShowModal() == wx.ID_NO:
  371. dlg.Destroy()
  372. return False
  373. dlg.Destroy()
  374. return True
  375. def OnSetParameters(self, event):
  376. """Handle setting CLI parameters for the script (asks for input)"""
  377. dlg = wx.TextEntryDialog(
  378. parent=self.guiparent,
  379. caption=_("Set parameters for the script"),
  380. message=_("Specify command line parameters for the script separated by spaces:"),
  381. )
  382. if self.parameters:
  383. dlg.SetValue(" ".join(self.parameters))
  384. # TODO: modality might not be needed here if we bind the events
  385. if dlg.ShowModal() == wx.ID_OK:
  386. text = dlg.GetValue().strip()
  387. # TODO: split in the same way as in console
  388. if text:
  389. self.parameters = text.split()
  390. else:
  391. self.parameters = None
  392. def OnHelp(self, event):
  393. # inspired by g.manual but simple not using GRASS_HTML_BROWSER
  394. # not using g.manual because it does not show
  395. entry = 'libpython/script_intro.html'
  396. major, minor, patch = gscript.version()['version'].split('.')
  397. url = 'http://grass.osgeo.org/grass%s%s/manuals/%s' % (
  398. major, minor, entry)
  399. open_url(url)
  400. def OnPythonHelp(self, event):
  401. url = 'https://docs.python.org/%s/tutorial/' % sys.version_info[0]
  402. open_url(url)
  403. def OnModulesHelp(self, event):
  404. self.giface.Help('full_index')
  405. def OnSubmittingHelp(self, event):
  406. open_url('https://trac.osgeo.org/grass/wiki/Submitting/Python')
  407. def OnAddonsHelp(self, event):
  408. open_url('https://grass.osgeo.org/development/code-submission/')
  409. def OnSupport(self, event):
  410. open_url('https://grass.osgeo.org/support/')
  411. class PyEditToolbar(BaseToolbar):
  412. # GUI class
  413. # pylint: disable=too-many-ancestors
  414. # pylint: disable=too-many-public-methods
  415. """PyEdit toolbar"""
  416. def __init__(self, parent):
  417. BaseToolbar.__init__(self, parent)
  418. self.icons = {
  419. 'open': MetaIcon(img='open',
  420. label=_('Open (Ctrl+O)')),
  421. 'save': MetaIcon(img='save',
  422. label=_('Save (Ctrl+S)')),
  423. 'run': MetaIcon(img='execute',
  424. label=_('Run (Ctrl+R)')),
  425. # TODO: better icons for overwrite modes
  426. 'overwriteTrue': MetaIcon(img='locked',
  427. label=_('Activate overwrite')),
  428. 'overwriteFalse': MetaIcon(img='unlocked',
  429. label=_('Deactive overwrite')),
  430. }
  431. # workaround for http://trac.wxwidgets.org/ticket/13888
  432. if sys.platform == 'darwin':
  433. parent.SetToolBar(self)
  434. self.InitToolbar(self._toolbarData())
  435. # realize the toolbar
  436. self.Realize()
  437. def _toolbarData(self):
  438. """Toolbar data"""
  439. return self._getToolbarData((('open', self.icons['open'],
  440. self.parent.OnOpen),
  441. ('save', self.icons['save'],
  442. self.parent.OnSave),
  443. (None, ),
  444. ('run', self.icons['run'],
  445. self.parent.OnRun),
  446. ('overwrite', self.icons['overwriteTrue'],
  447. self.OnSetOverwrite, wx.ITEM_CHECK),
  448. (None, ),
  449. ("help", BaseIcons['help'],
  450. self.parent.OnHelp),
  451. ))
  452. # TODO: add overwrite also to the menu and sync with toolbar
  453. def OnSetOverwrite(self, event):
  454. if self.GetToolState(self.overwrite):
  455. self.SetToolNormalBitmap(self.overwrite,
  456. self.icons['overwriteFalse'].GetBitmap())
  457. self.SetToolShortHelp(self.overwrite,
  458. self.icons['overwriteFalse'].GetLabel())
  459. self.parent.overwrite = True
  460. else:
  461. self.SetToolNormalBitmap(self.overwrite,
  462. self.icons['overwriteTrue'].GetBitmap())
  463. self.SetToolShortHelp(self.overwrite,
  464. self.icons['overwriteTrue'].GetLabel())
  465. self.parent.overwrite = False
  466. class PyEditFrame(wx.Frame):
  467. # GUI class and a lot of trampoline methods
  468. # pylint: disable=missing-docstring
  469. # pylint: disable=too-many-public-methods
  470. # pylint: disable=invalid-name
  471. def __init__(self, parent, giface, id=wx.ID_ANY,
  472. title=_("GRASS GIS Simple Python Editor"),
  473. **kwargs):
  474. wx.Frame.__init__(self, parent=parent, id=id, title=title, **kwargs)
  475. self.parent = parent
  476. filename = os.path.join(
  477. globalvar.WXGUIDIR, 'xml', 'menudata_pyedit.xml')
  478. self.menubar = Menu(
  479. parent=self,
  480. model=MenuTreeModelBuilder(filename).GetModel(separators=True))
  481. self.SetMenuBar(self.menubar)
  482. self.toolbar = PyEditToolbar(parent=self)
  483. # workaround for http://trac.wxwidgets.org/ticket/13888
  484. # TODO: toolbar is set in toolbar and here
  485. if sys.platform != 'darwin':
  486. self.SetToolBar(self.toolbar)
  487. self.panel = PyStc(parent=self)
  488. self.controller = PyEditController(
  489. panel=self.panel, guiparent=self, giface=giface)
  490. # don't start with an empty page
  491. self.panel.SetText(script_template())
  492. sizer = wx.BoxSizer(wx.VERTICAL)
  493. sizer.Add(self.panel, proportion=1,
  494. flag=wx.EXPAND)
  495. sizer.Fit(self)
  496. sizer.SetSizeHints(self)
  497. self.SetSizer(sizer)
  498. self.Fit()
  499. self.SetAutoLayout(True)
  500. self.Layout()
  501. self.Bind(wx.EVT_CLOSE, self.OnClose)
  502. # TODO: it would be nice if we can pass the controller to the menu
  503. # might not be possible on the side of menu
  504. # here we use self and self.controller which might make it harder
  505. def OnOpen(self, *args, **kwargs):
  506. self.controller.OnOpen(*args, **kwargs)
  507. def OnSave(self, *args, **kwargs):
  508. self.controller.OnSave(*args, **kwargs)
  509. def OnClose(self, *args, **kwargs):
  510. # this will be often true because PyStc is using EVT_KEY_DOWN
  511. # to say if it was modified, not actual user change in text
  512. if self.controller.IsContentValuable():
  513. self.controller.OnSave(*args, **kwargs)
  514. self.Destroy()
  515. def OnRun(self, *args, **kwargs):
  516. # save without asking
  517. self.controller.OnRun(*args, **kwargs)
  518. def OnHelp(self, *args, **kwargs):
  519. self.controller.OnHelp(*args, **kwargs)
  520. def OnSimpleScriptTemplate(self, *args, **kwargs):
  521. self.controller.SetScriptTemplate(*args, **kwargs)
  522. def OnGrassModuleTemplate(self, *args, **kwargs):
  523. self.controller.SetModuleTemplate(*args, **kwargs)
  524. def OnSimpleScriptExample(self, *args, **kwargs):
  525. self.controller.SetScriptExample(*args, **kwargs)
  526. def OnGrassModuleExample(self, *args, **kwargs):
  527. self.controller.SetModuleExample(*args, **kwargs)
  528. def OnGrassModuleErrorHandlingExample(self, *args, **kwargs):
  529. self.controller.SetModuleErrorHandlingExample(*args, **kwargs)
  530. def OnPythonHelp(self, *args, **kwargs):
  531. self.controller.OnPythonHelp(*args, **kwargs)
  532. def OnModulesHelp(self, *args, **kwargs):
  533. self.controller.OnModulesHelp(*args, **kwargs)
  534. def OnSubmittingHelp(self, *args, **kwargs):
  535. self.controller.OnSubmittingHelp(*args, **kwargs)
  536. def OnAddonsHelp(self, *args, **kwargs):
  537. self.controller.OnAddonsHelp(*args, **kwargs)
  538. def OnSupport(self, *args, **kwargs):
  539. self.controller.OnSupport(*args, **kwargs)
  540. def _get_overwrite(self):
  541. return self.controller.overwrite
  542. def _set_overwrite(self, overwrite):
  543. self.controller.overwrite = overwrite
  544. overwrite = property(_get_overwrite, _set_overwrite,
  545. doc="Tells if overwrite should be used")
  546. def OnSetParameters(self, *args, **kwargs):
  547. self.controller.OnSetParameters(*args, **kwargs)
  548. def main():
  549. """Test application (potentially useful as g.gui.pyedit)"""
  550. from core.giface import StandaloneGrassInterface
  551. app = wx.App()
  552. giface = StandaloneGrassInterface()
  553. simple_editor = PyEditFrame(parent=None, giface=giface)
  554. simple_editor.SetSize((600, 800))
  555. simple_editor.Show()
  556. app.MainLoop()
  557. if __name__ == '__main__':
  558. main()