locdownload.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  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 tempfile
  17. import shutil
  18. try:
  19. from urllib2 import HTTPError, URLError
  20. from urllib import urlopen, urlretrieve
  21. except ImportError:
  22. # there is also HTTPException, perhaps change to list
  23. from urllib.error import HTTPError, URLError
  24. from urllib.request import urlopen, urlretrieve
  25. import wx
  26. from wx.lib.newevent import NewEvent
  27. from grass.script import debug
  28. from grass.script.utils import try_rmdir
  29. from grass.script.setup import set_gui_path
  30. set_gui_path()
  31. from core.debug import Debug
  32. from core.utils import _
  33. from core.gthread import gThread
  34. from gui_core.wrap import Button
  35. # TODO: labels (and descriptions) translatable?
  36. LOCATIONS = [
  37. {
  38. "label": "Complete NC location",
  39. "url": "https://grass.osgeo.org/sampledata/north_carolina/nc_spm_08_grass7.tar.gz",
  40. },
  41. {
  42. "label": "Basic NC location",
  43. "url": "https://grass.osgeo.org/sampledata/north_carolina/nc_basic_spm_grass7.tar.gz",
  44. },
  45. {
  46. "label": "World location in LatLong/WGS84",
  47. "url": "https://grass.osgeo.org/sampledata/worldlocation.tar.gz",
  48. },
  49. {
  50. "label": "Spearfish (SD) location",
  51. "url": "https://grass.osgeo.org/sampledata/spearfish_grass70data-0.3.tar.gz",
  52. },
  53. {
  54. "label": "Piemonte, Italy data set",
  55. "url": "http://geodati.fmach.it/gfoss_geodata/libro_gfoss/grassdata_piemonte_utm32n_wgs84_grass7.tar.gz",
  56. },
  57. {
  58. "label": "Slovakia 3D precipitation voxel data set",
  59. "url": "https://grass.osgeo.org/uploads/grass/sampledata/slovakia3d_grass7.tar.gz",
  60. },
  61. {
  62. "label": "Fire simulation sample data",
  63. "url": "https://grass.osgeo.org/sampledata/fire_grass6data.tar.gz",
  64. },
  65. ]
  66. class DownloadError(Exception):
  67. """Error happened during download or when processing the file"""
  68. pass
  69. # copy from g.extension, potentially move to library
  70. def move_extracted_files(extract_dir, target_dir, files):
  71. """Fix state of extracted file by moving them to different diretcory
  72. When extracting, it is not clear what will be the root directory
  73. or if there will be one at all. So this function moves the files to
  74. a different directory in the way that if there was one directory extracted,
  75. the contained files are moved.
  76. """
  77. debug("move_extracted_files({0})".format(locals()))
  78. if len(files) == 1:
  79. shutil.copytree(os.path.join(extract_dir, files[0]), target_dir)
  80. else:
  81. if not os.path.exists(target_dir):
  82. os.mkdir(target_dir)
  83. for file_name in files:
  84. actual_file = os.path.join(extract_dir, file_name)
  85. if os.path.isdir(actual_file):
  86. # copy_tree() from distutils failed to create
  87. # directories before copying files time to time
  88. # (when copying to recently deleted directory)
  89. shutil.copytree(actual_file,
  90. os.path.join(target_dir, file_name))
  91. else:
  92. shutil.copy(actual_file, os.path.join(target_dir, file_name))
  93. # copy from g.extension, potentially move to library
  94. def extract_zip(name, directory, tmpdir):
  95. """Extract a ZIP file into a directory"""
  96. debug("extract_zip(name={name}, directory={directory},"
  97. " tmpdir={tmpdir})".format(name=name, directory=directory,
  98. tmpdir=tmpdir), 3)
  99. try:
  100. import zipfile
  101. zip_file = zipfile.ZipFile(name, mode='r')
  102. file_list = zip_file.namelist()
  103. # we suppose we can write to parent of the given dir
  104. # (supposing a tmp dir)
  105. extract_dir = os.path.join(tmpdir, 'extract_dir')
  106. os.mkdir(extract_dir)
  107. for subfile in file_list:
  108. # this should be safe in Python 2.7.4
  109. zip_file.extract(subfile, extract_dir)
  110. files = os.listdir(extract_dir)
  111. move_extracted_files(extract_dir=extract_dir,
  112. target_dir=directory, files=files)
  113. except zipfile.BadZipfile as error:
  114. raise DownloadError(_("ZIP file is unreadable: {0}").format(error))
  115. # copy from g.extension, potentially move to library
  116. def extract_tar(name, directory, tmpdir):
  117. """Extract a TAR or a similar file into a directory"""
  118. debug("extract_tar(name={name}, directory={directory},"
  119. " tmpdir={tmpdir})".format(name=name, directory=directory,
  120. tmpdir=tmpdir), 3)
  121. try:
  122. import tarfile # we don't need it anywhere else
  123. tar = tarfile.open(name)
  124. extract_dir = os.path.join(tmpdir, 'extract_dir')
  125. os.mkdir(extract_dir)
  126. tar.extractall(path=extract_dir)
  127. files = os.listdir(extract_dir)
  128. move_extracted_files(extract_dir=extract_dir,
  129. target_dir=directory, files=files)
  130. except tarfile.TarError as error:
  131. raise DownloadError(_("Archive file is unreadable: {0}").format(error))
  132. extract_tar.supported_formats = ['tar.gz', 'gz', 'bz2', 'tar', 'gzip', 'targz']
  133. # based on g.extension, potentially move to library
  134. def download_end_extract(source):
  135. """Download a file (archive) from URL and uncompress it"""
  136. tmpdir = tempfile.mkdtemp()
  137. directory = os.path.join(tmpdir, 'location')
  138. if source.endswith('.zip'):
  139. archive_name = os.path.join(tmpdir, 'location.zip')
  140. filename, headers = urlretrieve(source, archive_name)
  141. if headers.get('content-type', '') != 'application/zip':
  142. raise DownloadError(
  143. _("Download of <{url}> failed"
  144. " or file <{name}> is not a ZIP file").format(
  145. url=source, name=filename))
  146. extract_zip(name=archive_name, directory=directory, tmpdir=tmpdir)
  147. elif (source.endswith(".tar.gz") or
  148. source.rsplit('.', 1)[1] in extract_tar.supported_formats):
  149. if source.endswith(".tar.gz"):
  150. ext = "tar.gz"
  151. else:
  152. ext = source.rsplit('.', 1)[1]
  153. archive_name = os.path.join(tmpdir, 'extension.' + ext)
  154. urlretrieve(source, archive_name)
  155. # TODO: error handling for urlretrieve
  156. extract_tar(name=archive_name, directory=directory, tmpdir=tmpdir)
  157. else:
  158. # probably programmer error
  159. raise DownloadError(_("Unknown format '{0}'.").format(source))
  160. assert os.path.isdir(directory)
  161. return directory
  162. def download_location(url, name, database):
  163. """Wrapper to return DownloadError by value
  164. It also moves the location directory to the database.
  165. """
  166. try:
  167. # TODO: the unpacking could go right to the path (but less
  168. # robust) or replace copytree here with move
  169. directory = download_end_extract(source=url)
  170. destination = os.path.join(database, name)
  171. if not is_location_valid(directory):
  172. return _("Downloaded location is not valid")
  173. shutil.copytree(src=directory, dst=destination)
  174. try_rmdir(directory)
  175. except DownloadError as error:
  176. return error
  177. return None
  178. # based on grass.py (to be moved to future "grass.init")
  179. def is_location_valid(location):
  180. """Return True if GRASS Location is valid
  181. :param location: path of a Location
  182. """
  183. # DEFAULT_WIND file should not be required until you do something
  184. # that actually uses them. The check is just a heuristic; a directory
  185. # containing a PERMANENT/DEFAULT_WIND file is probably a GRASS
  186. # location, while a directory lacking it probably isn't.
  187. # TODO: perhaps we can relax this and require only permanent
  188. return os.access(os.path.join(location,
  189. "PERMANENT", "DEFAULT_WIND"), os.F_OK)
  190. def location_name_from_url(url):
  191. """Create location name from URL"""
  192. return url.rsplit('/', 1)[1].split('.', 1)[0].replace("-", "_").replace(" ", "_")
  193. DownloadDoneEvent, EVT_DOWNLOAD_DONE = NewEvent()
  194. class LocationDownloadPanel(wx.Panel):
  195. """Panel to select and initiate downloads of locations.
  196. Has a place to report errors to user and also any potential problems
  197. before the user hits the button.
  198. In the future, it can potentially show also some details about what
  199. will be downloaded. The choice widget can be also replaced.
  200. For the future, there can be multiple panels with different methods
  201. or sources, e.g. direct input of URL. These can be in separate tabs
  202. of one panel (perhaps sharing the common background download and
  203. message logic).
  204. """
  205. def __init__(self, parent, database, locations=LOCATIONS):
  206. """
  207. :param database: directory with G database to download to
  208. :param locations: list of dictionaries with label and url
  209. """
  210. wx.Panel.__init__(self, parent=parent)
  211. self._last_downloaded_location_name = None
  212. self._download_in_progress = False
  213. self.database = database
  214. self.locations = locations
  215. self.label = wx.StaticText(
  216. parent=self,
  217. label=_("Select from sample location at grass.osgeo.org"))
  218. choices = []
  219. for item in self.locations:
  220. choices.append(item['label'])
  221. self.choice = wx.Choice(parent=self, choices=choices)
  222. self.choice.Bind(wx.EVT_CHOICE, self.OnChangeChoice)
  223. self.download_button = Button(parent=self, id=wx.ID_ANY,
  224. label=_("Do&wnload"))
  225. self.download_button.SetToolTip(_("Download selected location"))
  226. self.download_button.Bind(wx.EVT_BUTTON, self.OnDownload)
  227. # TODO: add button for a link to an associated website?
  228. # TODO: add thumbnail for each location?
  229. # TODO: messages copied from gis_set.py, need this as API?
  230. self.message = wx.StaticText(parent=self, size=(-1, 50))
  231. # It is not clear if all wx versions supports color, so try-except.
  232. # The color itself may not be correct for all platforms/system settings
  233. # but in http://xoomer.virgilio.it/infinity77/wxPython/Widgets/wx.SystemSettings.html
  234. # there is no 'warning' color.
  235. try:
  236. self.message.SetForegroundColour(wx.Colour(255, 0, 0))
  237. except AttributeError:
  238. pass
  239. self._layout()
  240. default = 0
  241. self.choice.SetSelection(default)
  242. self.CheckItem(self.locations[default])
  243. self.thread = gThread()
  244. def _layout(self):
  245. """Create and layout sizers"""
  246. vertical = wx.BoxSizer(wx.VERTICAL)
  247. self.sizer = vertical
  248. vertical.Add(self.label, proportion=0,
  249. flag=wx.EXPAND | wx.ALL, border=10)
  250. vertical.Add(self.choice, proportion=0,
  251. flag=wx.EXPAND | wx.ALL, border=10)
  252. button_sizer = wx.BoxSizer(wx.HORIZONTAL)
  253. button_sizer.AddStretchSpacer()
  254. button_sizer.Add(self.download_button, proportion=0)
  255. vertical.Add(button_sizer, proportion=0,
  256. flag=wx.EXPAND | wx.ALL, border=10)
  257. vertical.AddStretchSpacer()
  258. vertical.Add(self.message, proportion=0,
  259. flag=wx.ALIGN_CENTER_VERTICAL |
  260. wx.ALIGN_LEFT | wx.ALL | wx.EXPAND, border=10)
  261. self.SetSizer(vertical)
  262. vertical.Fit(self)
  263. self.Layout()
  264. self.SetMinSize(self.GetBestSize())
  265. def OnDownload(self, event):
  266. """Handle user-initiated action of download"""
  267. Debug.msg(1, "OnDownload")
  268. if self._download_in_progress:
  269. self._warning(_("Download in progress, wait until it is finished"))
  270. index = self.choice.GetSelection()
  271. self.DownloadItem(self.locations[index])
  272. def DownloadItem(self, item):
  273. """Download the selected item"""
  274. Debug.msg(1, "DownloadItem: %s" % item)
  275. # similar code as in CheckItem
  276. url = item['url']
  277. dirname = location_name_from_url(url)
  278. destination = os.path.join(self.database, dirname)
  279. if os.path.exists(destination):
  280. self._error(_("Location named <%s> already exists,"
  281. " download canceled") % dirname)
  282. return
  283. def download_complete_callback(event):
  284. self._download_in_progress = False
  285. errors = event.ret
  286. if errors:
  287. self._error(_("Download failed: %s") % errors)
  288. else:
  289. self._last_downloaded_location_name = dirname
  290. self._warning(_("Download completed"))
  291. self._download_in_progress = True
  292. self._warning(_("Download in progress"))
  293. self.thread.Run(callable=download_location,
  294. url=url, name=dirname, database=self.database,
  295. ondone=download_complete_callback)
  296. def OnChangeChoice(self, event):
  297. """React to user changing the selection"""
  298. index = self.choice.GetSelection()
  299. self.CheckItem(self.locations[index])
  300. def CheckItem(self, item):
  301. """Check what user selected and report potential issues"""
  302. # similar code as in DownloadItem
  303. url = item['url']
  304. dirname = location_name_from_url(url)
  305. destination = os.path.join(self.database, dirname)
  306. if os.path.exists(destination):
  307. self._warning(_("Location named <%s> already exists,"
  308. " rename it first") % dirname)
  309. return
  310. else:
  311. self._clearMessage()
  312. def GetLocation(self):
  313. """Get the name of the last location downloaded by the user"""
  314. return self._last_downloaded_location_name
  315. def _warning(self, text):
  316. """Displays a warning, hint or info message to the user.
  317. This function can be used for all kinds of messages except for
  318. error messages.
  319. .. note::
  320. There is no cleaning procedure. You should call
  321. _clearMessage() when you know that there is everything
  322. correct.
  323. """
  324. self.message.SetLabel(text)
  325. self.sizer.Layout()
  326. def _error(self, text):
  327. """Displays a error message to the user.
  328. This function should be used only when something serious and unexpected
  329. happens, otherwise _showWarning should be used.
  330. .. note::
  331. There is no cleaning procedure. You should call
  332. _clearMessage() when you know that there is everything
  333. correct.
  334. """
  335. self.message.SetLabel(_("Error: {text}").format(text=text))
  336. self.sizer.Layout()
  337. def _clearMessage(self):
  338. """Clears/hides the error message."""
  339. # we do no hide widget
  340. # because we do not want the dialog to change the size
  341. self.message.SetLabel("")
  342. self.sizer.Layout()
  343. class LocationDownloadDialog(wx.Dialog):
  344. """Dialog for download of locations
  345. Contains the panel and Cancel button.
  346. """
  347. def __init__(self, parent, database,
  348. title=_("GRASS GIS Location Download")):
  349. """
  350. :param database: database to download the location to
  351. :param title: window title if the default is not appropriate
  352. """
  353. wx.Dialog.__init__(self, parent=parent, title=title)
  354. self.panel = LocationDownloadPanel(parent=self, database=database)
  355. close_button = Button(self, id=wx.ID_CLOSE)
  356. close_button.Bind(wx.EVT_BUTTON, lambda event: self.Close())
  357. sizer = wx.BoxSizer(wx.VERTICAL)
  358. sizer.Add(self.panel)
  359. button_sizer = wx.StdDialogButtonSizer()
  360. button_sizer.Add(close_button, flag=wx.EXPAND)
  361. button_sizer.Realize()
  362. sizer.Add(button_sizer, flag=wx.EXPAND | wx.ALL, border=10)
  363. self.SetSizer(sizer)
  364. sizer.Fit(self)
  365. self.Layout()
  366. def GetLocation(self):
  367. """Get the name of the last location downloaded by the user"""
  368. return self.panel.GetLocation()
  369. def main():
  370. """Tests the download dialog"""
  371. if len(sys.argv) < 2:
  372. sys.exit("Provide a test directory")
  373. database = sys.argv[1]
  374. app = wx.App()
  375. if len(sys.argv) == 2 or sys.argv[2] == 'dialog':
  376. window = LocationDownloadDialog(parent=None, database=database)
  377. window.ShowModal()
  378. location = window.GetLocation()
  379. if location:
  380. print(location)
  381. window.Destroy()
  382. elif sys.argv[2] == 'panel':
  383. window = wx.Dialog(parent=None)
  384. panel = LocationDownloadPanel(parent=window, database=database)
  385. window.ShowModal()
  386. location = panel.GetLocation()
  387. if location:
  388. print(location)
  389. window.Destroy()
  390. else:
  391. print("Unknown settings: try dialog or panel")
  392. app.MainLoop()
  393. if __name__ == '__main__':
  394. main()