locdownload.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. """
  2. @package startup.locdownload
  3. @brief GRASS Location Download Management
  4. Classes:
  5. - LocationDownloadPanel
  6. - LocationDownloadDialog
  7. - DownloadError
  8. (C) 2017 by Vaclav Petras the GRASS Development Team
  9. This program is free software under the GNU General Public License
  10. (>=v2). Read the file COPYING that comes with GRASS for details.
  11. @author Vaclav Petras <wenzeslaus gmail com>
  12. """
  13. from __future__ import print_function
  14. import os
  15. import sys
  16. import shutil
  17. import textwrap
  18. import time
  19. import wx
  20. from wx.lib.newevent import NewEvent
  21. from grass.script.utils import try_rmdir, legalize_vector_name
  22. from grass.utils.download import download_and_extract, name_from_url, DownloadError
  23. from grass.grassdb.checks import is_location_valid
  24. from grass.script.setup import set_gui_path
  25. set_gui_path()
  26. from core.debug import Debug
  27. from core.gthread import gThread
  28. from gui_core.wrap import Button, StaticText
  29. # TODO: labels (and descriptions) translatable?
  30. LOCATIONS = [
  31. {
  32. "label": "Complete NC location",
  33. "url": "https://grass.osgeo.org/sampledata/north_carolina/nc_spm_08_grass7.tar.gz",
  34. },
  35. {
  36. "label": "Basic NC location",
  37. "url": "https://grass.osgeo.org/sampledata/north_carolina/nc_basic_spm_grass7.tar.gz",
  38. },
  39. {
  40. "label": "World location in LatLong/WGS84",
  41. "url": "https://grass.osgeo.org/sampledata/worldlocation.tar.gz",
  42. },
  43. {
  44. "label": "Spearfish (SD) location",
  45. "url": "https://grass.osgeo.org/sampledata/spearfish_grass70data-0.3.tar.gz",
  46. },
  47. {
  48. "label": "Piemonte, Italy data set",
  49. "url": "http://geodati.fmach.it/gfoss_geodata/libro_gfoss/grassdata_piemonte_utm32n_wgs84_grass7.tar.gz",
  50. },
  51. {
  52. "label": "Slovakia 3D precipitation voxel data set",
  53. "url": "https://grass.osgeo.org/sampledata/slovakia3d_grass7.tar.gz",
  54. },
  55. {
  56. "label": "Fire simulation sample data",
  57. "url": "https://grass.osgeo.org/sampledata/fire_grass6data.tar.gz",
  58. },
  59. {
  60. "label": "GISMentors location, Czech Republic",
  61. "url": "http://training.gismentors.eu/geodata/grass/gismentors.zip",
  62. },
  63. {
  64. "label": "Natural Earth Dataset in WGS84",
  65. "url": "https://zenodo.org/record/3968936/files/natural-earth-dataset.tar.gz",
  66. "size": "207 MB",
  67. "epsg": "4326",
  68. "license": "ODC Public Domain Dedication and License 1.0",
  69. "maintainer": "Brendan Harmon (brendan.harmon@gmail.com)",
  70. },
  71. ]
  72. class RedirectText(object):
  73. def __init__(self, window):
  74. self.out = window
  75. def write(self, string):
  76. try:
  77. if self.out:
  78. string = self._wrap_string(string)
  79. heigth = self._get_heigth(string)
  80. wx.CallAfter(self.out.SetLabel, string)
  81. self._resize(heigth)
  82. except:
  83. # window closed -> PyDeadObjectError
  84. pass
  85. def flush(self):
  86. pass
  87. def _wrap_string(self, string, width=40):
  88. """Wrap string
  89. :param str string: input string
  90. :param int width: maximum length allowed of the wrapped lines
  91. :return str: newline-separated string
  92. """
  93. wrapper = textwrap.TextWrapper(width=width)
  94. return wrapper.fill(text=string)
  95. def _get_heigth(self, string):
  96. """Get widget new heigth
  97. :param str string: input string
  98. :return int: widget heigth
  99. """
  100. n_lines = string.count("\n")
  101. attr = self.out.GetClassDefaultAttributes()
  102. font_size = attr.font.GetPointSize()
  103. heigth = int((n_lines + 2) * font_size // 0.75) # 1 px = 0.75 pt
  104. return heigth
  105. def _resize(self, heigth=-1):
  106. """Resize widget heigth
  107. :param int heigth: widget heigth
  108. """
  109. wx.CallAfter(self.out.GetParent().SetMinSize, (-1, -1))
  110. wx.CallAfter(self.out.SetMinSize, (-1, heigth))
  111. wx.CallAfter(
  112. self.out.GetParent().parent.sizer.Fit,
  113. self.out.GetParent().parent,
  114. )
  115. # based on https://blog.shichao.io/2012/10/04/progress_speed_indicator_for_urlretrieve_in_python.html
  116. def reporthook(count, block_size, total_size):
  117. global start_time
  118. if count == 0:
  119. start_time = time.time()
  120. sys.stdout.write(
  121. _("Download in progress, wait until it is finished 0%"),
  122. )
  123. return
  124. if count % 100 != 0: # be less verbose
  125. return
  126. duration = time.time() - start_time
  127. progress_size = int(count * block_size)
  128. speed = int(progress_size / (1024 * duration))
  129. percent = int(count * block_size * 100 / total_size)
  130. sys.stdout.write(
  131. _(
  132. "Download in progress, wait until it is finished "
  133. "{0}%, {1} MB, {2} KB/s, {3:.0f} seconds passed".format(
  134. percent,
  135. progress_size / (1024 * 1024),
  136. speed,
  137. duration,
  138. ),
  139. ),
  140. )
  141. def download_location(url, name, database):
  142. """Wrapper to return DownloadError by value
  143. It also moves the location directory to the database.
  144. """
  145. try:
  146. # TODO: the unpacking could go right to the path (but less
  147. # robust) or replace copytree here with move
  148. directory = download_and_extract(source=url, reporthook=reporthook)
  149. destination = os.path.join(database, name)
  150. if not is_location_valid(directory):
  151. return _("Downloaded location is not valid")
  152. shutil.copytree(src=directory, dst=destination)
  153. try_rmdir(directory)
  154. except DownloadError as error:
  155. return error
  156. return None
  157. def location_name_from_url(url):
  158. """Create location name from URL"""
  159. return legalize_vector_name(name_from_url(url))
  160. DownloadDoneEvent, EVT_DOWNLOAD_DONE = NewEvent()
  161. class LocationDownloadPanel(wx.Panel):
  162. """Panel to select and initiate downloads of locations.
  163. Has a place to report errors to user and also any potential problems
  164. before the user hits the button.
  165. In the future, it can potentially show also some details about what
  166. will be downloaded. The choice widget can be also replaced.
  167. For the future, there can be multiple panels with different methods
  168. or sources, e.g. direct input of URL. These can be in separate tabs
  169. of one panel (perhaps sharing the common background download and
  170. message logic).
  171. """
  172. def __init__(self, parent, database, locations=LOCATIONS):
  173. """
  174. :param database: directory with G database to download to
  175. :param locations: list of dictionaries with label and url
  176. """
  177. wx.Panel.__init__(self, parent=parent)
  178. self.parent = parent
  179. self._last_downloaded_location_name = None
  180. self._download_in_progress = False
  181. self.database = database
  182. self.locations = locations
  183. self._abort_btn_label = _("Abort")
  184. self._abort_btn_tooltip = _("Abort download location")
  185. self.label = StaticText(
  186. parent=self, label=_("Select sample location to download:")
  187. )
  188. choices = []
  189. for item in self.locations:
  190. choices.append(item["label"])
  191. self.choice = wx.Choice(parent=self, choices=choices)
  192. self.choice.Bind(wx.EVT_CHOICE, self.OnChangeChoice)
  193. self.parent.download_button.Bind(wx.EVT_BUTTON, self.OnDownload)
  194. # TODO: add button for a link to an associated website?
  195. # TODO: add thumbnail for each location?
  196. # TODO: messages copied from gis_set.py, need this as API?
  197. self.message = StaticText(parent=self, size=(-1, 50))
  198. sys.stdout = RedirectText(self.message)
  199. # It is not clear if all wx versions supports color, so try-except.
  200. # The color itself may not be correct for all platforms/system settings
  201. # but in http://xoomer.virgilio.it/infinity77/wxPython/Widgets/wx.SystemSettings.html
  202. # there is no 'warning' color.
  203. try:
  204. self.message.SetForegroundColour(wx.Colour(255, 0, 0))
  205. except AttributeError:
  206. pass
  207. self._layout()
  208. default = 0
  209. self.choice.SetSelection(default)
  210. self.CheckItem(self.locations[default])
  211. self.thread = gThread()
  212. def _layout(self):
  213. """Create and layout sizers"""
  214. vertical = wx.BoxSizer(wx.VERTICAL)
  215. self.sizer = vertical
  216. vertical.Add(
  217. self.label,
  218. proportion=0,
  219. flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
  220. border=10,
  221. )
  222. vertical.Add(
  223. self.choice,
  224. proportion=0,
  225. flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
  226. border=10,
  227. )
  228. vertical.AddStretchSpacer()
  229. vertical.Add(
  230. self.message,
  231. proportion=0,
  232. flag=wx.ALIGN_LEFT | wx.ALL | wx.EXPAND,
  233. border=10,
  234. )
  235. self.SetSizer(vertical)
  236. vertical.Fit(self)
  237. self.Layout()
  238. self.SetMinSize(self.GetBestSize())
  239. def _change_download_btn_label(
  240. self, label=_("Do&wnload"), tooltip=_("Download selected location")
  241. ):
  242. """Change download button label/tooltip"""
  243. if self.parent.download_button:
  244. self.parent.download_button.SetLabel(label)
  245. self.parent.download_button.SetToolTip(tooltip)
  246. def OnDownload(self, event):
  247. """Handle user-initiated action of download"""
  248. button_label = self.parent.download_button.GetLabel()
  249. if button_label in (_("Download"), _("Do&wnload")):
  250. self._change_download_btn_label(
  251. label=self._abort_btn_label,
  252. tooltip=self._abort_btn_tooltip,
  253. )
  254. Debug.msg(1, "OnDownload")
  255. if self._download_in_progress:
  256. self._warning(_("Download in progress, wait until it is finished"))
  257. index = self.choice.GetSelection()
  258. self.DownloadItem(self.locations[index])
  259. else:
  260. self.parent.OnCancel()
  261. def DownloadItem(self, item):
  262. """Download the selected item"""
  263. Debug.msg(1, "DownloadItem: %s" % item)
  264. # similar code as in CheckItem
  265. url = item["url"]
  266. dirname = location_name_from_url(url)
  267. destination = os.path.join(self.database, dirname)
  268. if os.path.exists(destination):
  269. self._error(
  270. _("Location named <%s> already exists," " download canceled") % dirname
  271. )
  272. self._change_download_btn_label()
  273. return
  274. def download_complete_callback(event):
  275. self._download_in_progress = False
  276. errors = event.ret
  277. if errors:
  278. self._error(_("Download failed: %s") % errors)
  279. else:
  280. self._last_downloaded_location_name = dirname
  281. self._warning(
  282. _(
  283. "Download completed. The downloaded sample data is listed "
  284. "in the location/mapset tabs upon closing of this window"
  285. )
  286. )
  287. self._change_download_btn_label()
  288. def terminate_download_callback(event):
  289. # Clean up after urllib urlretrieve which is used internally
  290. # in grass.utils.
  291. from urllib import request # pylint: disable=import-outside-toplevel
  292. self._download_in_progress = False
  293. request.urlcleanup()
  294. sys.stdout.write("Download aborted")
  295. self.thread = gThread()
  296. self._change_download_btn_label()
  297. self._download_in_progress = True
  298. self._warning(_("Download in progress, wait until it is finished"))
  299. self.thread.Run(
  300. callable=download_location,
  301. url=url,
  302. name=dirname,
  303. database=self.database,
  304. ondone=download_complete_callback,
  305. onterminate=terminate_download_callback,
  306. )
  307. def OnChangeChoice(self, event):
  308. """React to user changing the selection"""
  309. index = self.choice.GetSelection()
  310. self.CheckItem(self.locations[index])
  311. def CheckItem(self, item):
  312. """Check what user selected and report potential issues"""
  313. # similar code as in DownloadItem
  314. url = item["url"]
  315. dirname = location_name_from_url(url)
  316. destination = os.path.join(self.database, dirname)
  317. if os.path.exists(destination):
  318. self._warning(
  319. _("Location named <%s> already exists," " rename it first") % dirname
  320. )
  321. self.parent.download_button.SetLabel(label=_("Download"))
  322. return
  323. else:
  324. self._clearMessage()
  325. def GetLocation(self):
  326. """Get the name of the last location downloaded by the user"""
  327. return self._last_downloaded_location_name
  328. def _warning(self, text):
  329. """Displays a warning, hint or info message to the user.
  330. This function can be used for all kinds of messages except for
  331. error messages.
  332. .. note::
  333. There is no cleaning procedure. You should call
  334. _clearMessage() when you know that there is everything
  335. correct.
  336. """
  337. sys.stdout.write(text)
  338. self.sizer.Layout()
  339. def _error(self, text):
  340. """Displays a error message to the user.
  341. This function should be used only when something serious and unexpected
  342. happens, otherwise _showWarning should be used.
  343. .. note::
  344. There is no cleaning procedure. You should call
  345. _clearMessage() when you know that there is everything
  346. correct.
  347. """
  348. sys.stdout.write(_("Error: {text}").format(text=text))
  349. self.sizer.Layout()
  350. def _clearMessage(self):
  351. """Clears/hides the error message."""
  352. # we do no hide widget
  353. # because we do not want the dialog to change the size
  354. self.message.SetLabel("")
  355. self.sizer.Layout()
  356. class LocationDownloadDialog(wx.Dialog):
  357. """Dialog for download of locations
  358. Contains the panel and Cancel button.
  359. """
  360. def __init__(self, parent, database, title=_("Location Download")):
  361. """
  362. :param database: database to download the location to
  363. :param title: window title if the default is not appropriate
  364. """
  365. wx.Dialog.__init__(self, parent=parent, title=title)
  366. cancel_button = Button(self, id=wx.ID_CANCEL)
  367. self.download_button = Button(parent=self, id=wx.ID_ANY, label=_("Do&wnload"))
  368. self.download_button.SetToolTip(_("Download selected location"))
  369. self.panel = LocationDownloadPanel(parent=self, database=database)
  370. cancel_button.Bind(wx.EVT_BUTTON, self.OnCancel)
  371. self.Bind(wx.EVT_CLOSE, self.OnCancel)
  372. self.sizer = wx.BoxSizer(wx.VERTICAL)
  373. self.sizer.Add(self.panel, proportion=1, flag=wx.EXPAND)
  374. button_sizer = wx.StdDialogButtonSizer()
  375. button_sizer.Add(
  376. cancel_button,
  377. proportion=0,
  378. flag=wx.EXPAND | wx.LEFT | wx.RIGHT,
  379. border=5,
  380. )
  381. button_sizer.Add(
  382. self.download_button,
  383. proportion=0,
  384. flag=wx.EXPAND | wx.LEFT | wx.RIGHT,
  385. border=5,
  386. )
  387. button_sizer.Realize()
  388. self.sizer.Add(
  389. button_sizer,
  390. proportion=0,
  391. flag=wx.ALIGN_RIGHT | wx.TOP | wx.BOTTOM,
  392. border=10,
  393. )
  394. self.SetSizer(self.sizer)
  395. self.sizer.Fit(self)
  396. self.Layout()
  397. def GetLocation(self):
  398. """Get the name of the last location downloaded by the user"""
  399. return self.panel.GetLocation()
  400. def OnCancel(self, event=None):
  401. if self.panel._download_in_progress:
  402. # running thread
  403. dlg = wx.MessageDialog(
  404. parent=self,
  405. message=_("Do you want to cancel location download?"),
  406. caption=_("Abort download"),
  407. style=wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION | wx.CENTRE,
  408. )
  409. ret = dlg.ShowModal()
  410. dlg.Destroy()
  411. if ret == wx.ID_NO:
  412. return
  413. else:
  414. self.panel.thread.Terminate()
  415. self.panel._change_download_btn_label()
  416. if event:
  417. self.EndModal(wx.ID_CANCEL)
  418. def main():
  419. """Tests the download dialog"""
  420. if len(sys.argv) < 2:
  421. sys.exit("Provide a test directory")
  422. database = sys.argv[1]
  423. app = wx.App()
  424. if len(sys.argv) == 2 or sys.argv[2] == "dialog":
  425. window = LocationDownloadDialog(parent=None, database=database)
  426. window.ShowModal()
  427. location = window.GetLocation()
  428. if location:
  429. print(location)
  430. window.Destroy()
  431. elif sys.argv[2] == "panel":
  432. window = wx.Dialog(parent=None)
  433. panel = LocationDownloadPanel(parent=window, database=database)
  434. window.ShowModal()
  435. location = panel.GetLocation()
  436. if location:
  437. print(location)
  438. window.Destroy()
  439. else:
  440. print("Unknown settings: try dialog or panel")
  441. app.MainLoop()
  442. if __name__ == "__main__":
  443. main()