pyedit.py 21 KB

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