locdownload.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  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. import textwrap
  19. import time
  20. try:
  21. from urllib2 import HTTPError, URLError
  22. from urllib import request, urlretrieve
  23. except ImportError:
  24. # there is also HTTPException, perhaps change to list
  25. from urllib.error import HTTPError, URLError
  26. from urllib.request import urlretrieve
  27. from urllib import request
  28. import wx
  29. from wx.lib.newevent import NewEvent
  30. from grass.script import debug
  31. from grass.script.utils import try_rmdir
  32. from grass.script.setup import set_gui_path
  33. set_gui_path()
  34. from core.debug import Debug
  35. from core.gthread import gThread
  36. from gui_core.wrap import Button, StaticText
  37. # TODO: labels (and descriptions) translatable?
  38. LOCATIONS = [
  39. {
  40. "label": "Complete NC location",
  41. "url": "https://grass.osgeo.org/sampledata/north_carolina/nc_spm_08_grass7.tar.gz",
  42. },
  43. {
  44. "label": "Basic NC location",
  45. "url": "https://grass.osgeo.org/sampledata/north_carolina/nc_basic_spm_grass7.tar.gz",
  46. },
  47. {
  48. "label": "World location in LatLong/WGS84",
  49. "url": "https://grass.osgeo.org/sampledata/worldlocation.tar.gz",
  50. },
  51. {
  52. "label": "Spearfish (SD) location",
  53. "url": "https://grass.osgeo.org/sampledata/spearfish_grass70data-0.3.tar.gz",
  54. },
  55. {
  56. "label": "Piemonte, Italy data set",
  57. "url": "http://geodati.fmach.it/gfoss_geodata/libro_gfoss/grassdata_piemonte_utm32n_wgs84_grass7.tar.gz",
  58. },
  59. {
  60. "label": "Slovakia 3D precipitation voxel data set",
  61. "url": "https://grass.osgeo.org/sampledata/slovakia3d_grass7.tar.gz",
  62. },
  63. {
  64. "label": "Fire simulation sample data",
  65. "url": "https://grass.osgeo.org/sampledata/fire_grass6data.tar.gz",
  66. },
  67. {
  68. "label": "GISMentors location, Czech Republic",
  69. "url": "http://training.gismentors.eu/geodata/grass/gismentors.zip",
  70. },
  71. {
  72. "label": "Natural Earth Dataset in WGS84",
  73. "url": "https://zenodo.org/record/3968936/files/natural-earth-dataset.tar.gz",
  74. "size": "207 MB",
  75. "epsg": "4326",
  76. "license": "ODC Public Domain Dedication and License 1.0",
  77. "maintainer": "Brendan Harmon (brendan.harmon@gmail.com)",
  78. },
  79. ]
  80. class DownloadError(Exception):
  81. """Error happened during download or when processing the file"""
  82. pass
  83. class RedirectText(object):
  84. def __init__(self, window):
  85. self.out = window
  86. def write(self, string):
  87. try:
  88. if self.out:
  89. string = self._wrap_string(string)
  90. heigth = self._get_heigth(string)
  91. wx.CallAfter(self.out.SetLabel, string)
  92. self._resize(heigth)
  93. except:
  94. # window closed -> PyDeadObjectError
  95. pass
  96. def flush(self):
  97. pass
  98. def _wrap_string(self, string, width=40):
  99. """Wrap string
  100. :param str string: input string
  101. :param int width: maximum length allowed of the wrapped lines
  102. :return str: newline-separated string
  103. """
  104. wrapper = textwrap.TextWrapper(width=width)
  105. return wrapper.fill(text=string)
  106. def _get_heigth(self, string):
  107. """Get widget new heigth
  108. :param str string: input string
  109. :return int: widget heigth
  110. """
  111. n_lines = string.count("\n")
  112. attr = self.out.GetClassDefaultAttributes()
  113. font_size = attr.font.GetPointSize()
  114. heigth = int((n_lines + 2) * font_size // 0.75) # 1 px = 0.75 pt
  115. return heigth
  116. def _resize(self, heigth=-1):
  117. """Resize widget heigth
  118. :param int heigth: widget heigth
  119. """
  120. wx.CallAfter(self.out.GetParent().SetMinSize, (-1, -1))
  121. wx.CallAfter(self.out.SetMinSize, (-1, heigth))
  122. wx.CallAfter(
  123. self.out.GetParent().parent.sizer.Fit,
  124. self.out.GetParent().parent,
  125. )
  126. # copy from g.extension, potentially move to library
  127. def move_extracted_files(extract_dir, target_dir, files):
  128. """Fix state of extracted file by moving them to different diretcory
  129. When extracting, it is not clear what will be the root directory
  130. or if there will be one at all. So this function moves the files to
  131. a different directory in the way that if there was one directory extracted,
  132. the contained files are moved.
  133. """
  134. debug("move_extracted_files({0})".format(locals()))
  135. if len(files) == 1:
  136. shutil.copytree(os.path.join(extract_dir, files[0]), target_dir)
  137. else:
  138. if not os.path.exists(target_dir):
  139. os.mkdir(target_dir)
  140. for file_name in files:
  141. actual_file = os.path.join(extract_dir, file_name)
  142. if os.path.isdir(actual_file):
  143. # copy_tree() from distutils failed to create
  144. # directories before copying files time to time
  145. # (when copying to recently deleted directory)
  146. shutil.copytree(actual_file, os.path.join(target_dir, file_name))
  147. else:
  148. shutil.copy(actual_file, os.path.join(target_dir, file_name))
  149. # copy from g.extension, potentially move to library
  150. def extract_zip(name, directory, tmpdir):
  151. """Extract a ZIP file into a directory"""
  152. debug(
  153. "extract_zip(name={name}, directory={directory},"
  154. " tmpdir={tmpdir})".format(name=name, directory=directory, tmpdir=tmpdir),
  155. 3,
  156. )
  157. try:
  158. import zipfile
  159. zip_file = zipfile.ZipFile(name, mode="r")
  160. file_list = zip_file.namelist()
  161. # we suppose we can write to parent of the given dir
  162. # (supposing a tmp dir)
  163. extract_dir = os.path.join(tmpdir, "extract_dir")
  164. os.mkdir(extract_dir)
  165. for subfile in file_list:
  166. # this should be safe in Python 2.7.4
  167. zip_file.extract(subfile, extract_dir)
  168. files = os.listdir(extract_dir)
  169. move_extracted_files(extract_dir=extract_dir, target_dir=directory, files=files)
  170. except zipfile.BadZipfile as error:
  171. raise DownloadError(_("ZIP file is unreadable: {0}").format(error))
  172. # copy from g.extension, potentially move to library
  173. def extract_tar(name, directory, tmpdir):
  174. """Extract a TAR or a similar file into a directory"""
  175. debug(
  176. "extract_tar(name={name}, directory={directory},"
  177. " tmpdir={tmpdir})".format(name=name, directory=directory, tmpdir=tmpdir),
  178. 3,
  179. )
  180. try:
  181. import tarfile # we don't need it anywhere else
  182. tar = tarfile.open(name)
  183. extract_dir = os.path.join(tmpdir, "extract_dir")
  184. os.mkdir(extract_dir)
  185. tar.extractall(path=extract_dir)
  186. files = os.listdir(extract_dir)
  187. move_extracted_files(extract_dir=extract_dir, target_dir=directory, files=files)
  188. except tarfile.TarError as error:
  189. raise DownloadError(_("Archive file is unreadable: {0}").format(error))
  190. extract_tar.supported_formats = ["tar.gz", "gz", "bz2", "tar", "gzip", "targz"]
  191. # based on https://blog.shichao.io/2012/10/04/progress_speed_indicator_for_urlretrieve_in_python.html
  192. def reporthook(count, block_size, total_size):
  193. global start_time
  194. if count == 0:
  195. start_time = time.time()
  196. sys.stdout.write(
  197. _("Download in progress, wait until it is finished 0%"),
  198. )
  199. return
  200. if count % 100 != 0: # be less verbose
  201. return
  202. duration = time.time() - start_time
  203. progress_size = int(count * block_size)
  204. speed = int(progress_size / (1024 * duration))
  205. percent = int(count * block_size * 100 / total_size)
  206. sys.stdout.write(
  207. _(
  208. "Download in progress, wait until it is finished "
  209. "{0}%, {1} MB, {2} KB/s, {3:.0f} seconds passed".format(
  210. percent,
  211. progress_size / (1024 * 1024),
  212. speed,
  213. duration,
  214. ),
  215. ),
  216. )
  217. # based on g.extension, potentially move to library
  218. def download_and_extract(source):
  219. """Download a file (archive) from URL and uncompress it"""
  220. tmpdir = tempfile.mkdtemp()
  221. Debug.msg(1, "Tmpdir: {}".format(tmpdir))
  222. directory = os.path.join(tmpdir, "location")
  223. http_error_message = _("Download file from <{url}>, " "return status code {code}, ")
  224. url_error_message = _(
  225. "Download file from <{url}>, " "failed. Check internet connection."
  226. )
  227. if source.endswith(".zip"):
  228. archive_name = os.path.join(tmpdir, "location.zip")
  229. try:
  230. filename, headers = urlretrieve(source, archive_name, reporthook)
  231. except HTTPError as err:
  232. raise DownloadError(
  233. http_error_message.format(
  234. url=source,
  235. code=err,
  236. ),
  237. )
  238. except URLError:
  239. raise DownloadError(url_error_message.format(url=source))
  240. if headers.get("content-type", "") != "application/zip":
  241. raise DownloadError(
  242. _(
  243. "Download of <{url}> failed" " or file <{name}> is not a ZIP file"
  244. ).format(url=source, name=filename)
  245. )
  246. extract_zip(name=archive_name, directory=directory, tmpdir=tmpdir)
  247. elif (
  248. source.endswith(".tar.gz")
  249. or source.rsplit(".", 1)[1] in extract_tar.supported_formats
  250. ):
  251. if source.endswith(".tar.gz"):
  252. ext = "tar.gz"
  253. else:
  254. ext = source.rsplit(".", 1)[1]
  255. archive_name = os.path.join(tmpdir, "location." + ext)
  256. try:
  257. urlretrieve(source, archive_name, reporthook)
  258. except HTTPError as err:
  259. raise DownloadError(
  260. http_error_message.format(
  261. url=source,
  262. code=err,
  263. ),
  264. )
  265. except URLError:
  266. raise DownloadError(url_error_message.format(url=source))
  267. extract_tar(name=archive_name, directory=directory, tmpdir=tmpdir)
  268. else:
  269. # probably programmer error
  270. raise DownloadError(_("Unknown format '{0}'.").format(source))
  271. assert os.path.isdir(directory)
  272. return directory
  273. def download_location(url, name, database):
  274. """Wrapper to return DownloadError by value
  275. It also moves the location directory to the database.
  276. """
  277. try:
  278. # TODO: the unpacking could go right to the path (but less
  279. # robust) or replace copytree here with move
  280. directory = download_and_extract(source=url)
  281. destination = os.path.join(database, name)
  282. if not is_location_valid(directory):
  283. return _("Downloaded location is not valid")
  284. shutil.copytree(src=directory, dst=destination)
  285. try_rmdir(directory)
  286. except DownloadError as error:
  287. return error
  288. return None
  289. # based on grass.py (to be moved to future "grass.init")
  290. def is_location_valid(location):
  291. """Return True if GRASS Location is valid
  292. :param location: path of a Location
  293. """
  294. # DEFAULT_WIND file should not be required until you do something
  295. # that actually uses them. The check is just a heuristic; a directory
  296. # containing a PERMANENT/DEFAULT_WIND file is probably a GRASS
  297. # location, while a directory lacking it probably isn't.
  298. # TODO: perhaps we can relax this and require only permanent
  299. return os.access(os.path.join(location, "PERMANENT", "DEFAULT_WIND"), os.F_OK)
  300. def location_name_from_url(url):
  301. """Create location name from URL"""
  302. return url.rsplit("/", 1)[1].split(".", 1)[0].replace("-", "_").replace(" ", "_")
  303. DownloadDoneEvent, EVT_DOWNLOAD_DONE = NewEvent()
  304. class LocationDownloadPanel(wx.Panel):
  305. """Panel to select and initiate downloads of locations.
  306. Has a place to report errors to user and also any potential problems
  307. before the user hits the button.
  308. In the future, it can potentially show also some details about what
  309. will be downloaded. The choice widget can be also replaced.
  310. For the future, there can be multiple panels with different methods
  311. or sources, e.g. direct input of URL. These can be in separate tabs
  312. of one panel (perhaps sharing the common background download and
  313. message logic).
  314. """
  315. def __init__(self, parent, database, locations=LOCATIONS):
  316. """
  317. :param database: directory with G database to download to
  318. :param locations: list of dictionaries with label and url
  319. """
  320. wx.Panel.__init__(self, parent=parent)
  321. self.parent = parent
  322. self._last_downloaded_location_name = None
  323. self._download_in_progress = False
  324. self.database = database
  325. self.locations = locations
  326. self._abort_btn_label = _("Abort")
  327. self._abort_btn_tooltip = _("Abort download location")
  328. self.label = StaticText(
  329. parent=self, label=_("Select sample location to download:")
  330. )
  331. choices = []
  332. for item in self.locations:
  333. choices.append(item["label"])
  334. self.choice = wx.Choice(parent=self, choices=choices)
  335. self.choice.Bind(wx.EVT_CHOICE, self.OnChangeChoice)
  336. self.parent.download_button.Bind(wx.EVT_BUTTON, self.OnDownload)
  337. # TODO: add button for a link to an associated website?
  338. # TODO: add thumbnail for each location?
  339. # TODO: messages copied from gis_set.py, need this as API?
  340. self.message = StaticText(parent=self, size=(-1, 50))
  341. sys.stdout = RedirectText(self.message)
  342. # It is not clear if all wx versions supports color, so try-except.
  343. # The color itself may not be correct for all platforms/system settings
  344. # but in http://xoomer.virgilio.it/infinity77/wxPython/Widgets/wx.SystemSettings.html
  345. # there is no 'warning' color.
  346. try:
  347. self.message.SetForegroundColour(wx.Colour(255, 0, 0))
  348. except AttributeError:
  349. pass
  350. self._layout()
  351. default = 0
  352. self.choice.SetSelection(default)
  353. self.CheckItem(self.locations[default])
  354. self.thread = gThread()
  355. def _layout(self):
  356. """Create and layout sizers"""
  357. vertical = wx.BoxSizer(wx.VERTICAL)
  358. self.sizer = vertical
  359. vertical.Add(
  360. self.label,
  361. proportion=0,
  362. flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
  363. border=10,
  364. )
  365. vertical.Add(
  366. self.choice,
  367. proportion=0,
  368. flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
  369. border=10,
  370. )
  371. vertical.AddStretchSpacer()
  372. vertical.Add(
  373. self.message,
  374. proportion=0,
  375. flag=wx.ALIGN_LEFT | wx.ALL | wx.EXPAND,
  376. border=10,
  377. )
  378. self.SetSizer(vertical)
  379. vertical.Fit(self)
  380. self.Layout()
  381. self.SetMinSize(self.GetBestSize())
  382. def _change_download_btn_label(
  383. self, label=_("Do&wnload"), tooltip=_("Download selected location")
  384. ):
  385. """Change download button label/tooltip"""
  386. if self.parent.download_button:
  387. self.parent.download_button.SetLabel(label)
  388. self.parent.download_button.SetToolTip(tooltip)
  389. def OnDownload(self, event):
  390. """Handle user-initiated action of download"""
  391. button_label = self.parent.download_button.GetLabel()
  392. if button_label in (_("Download"), _("Do&wnload")):
  393. self._change_download_btn_label(
  394. label=self._abort_btn_label,
  395. tooltip=self._abort_btn_tooltip,
  396. )
  397. Debug.msg(1, "OnDownload")
  398. if self._download_in_progress:
  399. self._warning(_("Download in progress, wait until it is finished"))
  400. index = self.choice.GetSelection()
  401. self.DownloadItem(self.locations[index])
  402. else:
  403. self.parent.OnCancel()
  404. def DownloadItem(self, item):
  405. """Download the selected item"""
  406. Debug.msg(1, "DownloadItem: %s" % item)
  407. # similar code as in CheckItem
  408. url = item["url"]
  409. dirname = location_name_from_url(url)
  410. destination = os.path.join(self.database, dirname)
  411. if os.path.exists(destination):
  412. self._error(
  413. _("Location named <%s> already exists," " download canceled") % dirname
  414. )
  415. self._change_download_btn_label()
  416. return
  417. def download_complete_callback(event):
  418. self._download_in_progress = False
  419. errors = event.ret
  420. if errors:
  421. self._error(_("Download failed: %s") % errors)
  422. else:
  423. self._last_downloaded_location_name = dirname
  424. self._warning(
  425. _(
  426. "Download completed. The downloaded sample data is listed "
  427. "in the location/mapset tabs upon closing of this window"
  428. )
  429. )
  430. self._change_download_btn_label()
  431. def terminate_download_callback(event):
  432. self._download_in_progress = False
  433. request.urlcleanup()
  434. sys.stdout.write("Download aborted")
  435. self.thread = gThread()
  436. self._change_download_btn_label()
  437. self._download_in_progress = True
  438. self._warning(_("Download in progress, wait until it is finished"))
  439. self.thread.Run(
  440. callable=download_location,
  441. url=url,
  442. name=dirname,
  443. database=self.database,
  444. ondone=download_complete_callback,
  445. onterminate=terminate_download_callback,
  446. )
  447. def OnChangeChoice(self, event):
  448. """React to user changing the selection"""
  449. index = self.choice.GetSelection()
  450. self.CheckItem(self.locations[index])
  451. def CheckItem(self, item):
  452. """Check what user selected and report potential issues"""
  453. # similar code as in DownloadItem
  454. url = item["url"]
  455. dirname = location_name_from_url(url)
  456. destination = os.path.join(self.database, dirname)
  457. if os.path.exists(destination):
  458. self._warning(
  459. _("Location named <%s> already exists," " rename it first") % dirname
  460. )
  461. self.parent.download_button.SetLabel(label=_("Download"))
  462. return
  463. else:
  464. self._clearMessage()
  465. def GetLocation(self):
  466. """Get the name of the last location downloaded by the user"""
  467. return self._last_downloaded_location_name
  468. def _warning(self, text):
  469. """Displays a warning, hint or info message to the user.
  470. This function can be used for all kinds of messages except for
  471. error messages.
  472. .. note::
  473. There is no cleaning procedure. You should call
  474. _clearMessage() when you know that there is everything
  475. correct.
  476. """
  477. sys.stdout.write(text)
  478. self.sizer.Layout()
  479. def _error(self, text):
  480. """Displays a error message to the user.
  481. This function should be used only when something serious and unexpected
  482. happens, otherwise _showWarning should be used.
  483. .. note::
  484. There is no cleaning procedure. You should call
  485. _clearMessage() when you know that there is everything
  486. correct.
  487. """
  488. sys.stdout.write(_("Error: {text}").format(text=text))
  489. self.sizer.Layout()
  490. def _clearMessage(self):
  491. """Clears/hides the error message."""
  492. # we do no hide widget
  493. # because we do not want the dialog to change the size
  494. self.message.SetLabel("")
  495. self.sizer.Layout()
  496. class LocationDownloadDialog(wx.Dialog):
  497. """Dialog for download of locations
  498. Contains the panel and Cancel button.
  499. """
  500. def __init__(self, parent, database, title=_("Location Download")):
  501. """
  502. :param database: database to download the location to
  503. :param title: window title if the default is not appropriate
  504. """
  505. wx.Dialog.__init__(self, parent=parent, title=title)
  506. cancel_button = Button(self, id=wx.ID_CANCEL)
  507. self.download_button = Button(parent=self, id=wx.ID_ANY, label=_("Do&wnload"))
  508. self.download_button.SetToolTip(_("Download selected location"))
  509. self.panel = LocationDownloadPanel(parent=self, database=database)
  510. cancel_button.Bind(wx.EVT_BUTTON, self.OnCancel)
  511. self.Bind(wx.EVT_CLOSE, self.OnCancel)
  512. self.sizer = wx.BoxSizer(wx.VERTICAL)
  513. self.sizer.Add(self.panel, proportion=1, flag=wx.EXPAND)
  514. button_sizer = wx.StdDialogButtonSizer()
  515. button_sizer.Add(
  516. cancel_button,
  517. proportion=0,
  518. flag=wx.EXPAND | wx.LEFT | wx.RIGHT,
  519. border=5,
  520. )
  521. button_sizer.Add(
  522. self.download_button,
  523. proportion=0,
  524. flag=wx.EXPAND | wx.LEFT | wx.RIGHT,
  525. border=5,
  526. )
  527. button_sizer.Realize()
  528. self.sizer.Add(
  529. button_sizer,
  530. proportion=0,
  531. flag=wx.ALIGN_RIGHT | wx.TOP | wx.BOTTOM,
  532. border=10,
  533. )
  534. self.SetSizer(self.sizer)
  535. self.sizer.Fit(self)
  536. self.Layout()
  537. def GetLocation(self):
  538. """Get the name of the last location downloaded by the user"""
  539. return self.panel.GetLocation()
  540. def OnCancel(self, event=None):
  541. if self.panel._download_in_progress:
  542. # running thread
  543. dlg = wx.MessageDialog(
  544. parent=self,
  545. message=_("Do you want to cancel location download?"),
  546. caption=_("Abort download"),
  547. style=wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION | wx.CENTRE,
  548. )
  549. ret = dlg.ShowModal()
  550. dlg.Destroy()
  551. if ret == wx.ID_NO:
  552. return
  553. else:
  554. self.panel.thread.Terminate()
  555. self.panel._change_download_btn_label()
  556. if event:
  557. self.EndModal(wx.ID_CANCEL)
  558. def main():
  559. """Tests the download dialog"""
  560. if len(sys.argv) < 2:
  561. sys.exit("Provide a test directory")
  562. database = sys.argv[1]
  563. app = wx.App()
  564. if len(sys.argv) == 2 or sys.argv[2] == "dialog":
  565. window = LocationDownloadDialog(parent=None, database=database)
  566. window.ShowModal()
  567. location = window.GetLocation()
  568. if location:
  569. print(location)
  570. window.Destroy()
  571. elif sys.argv[2] == "panel":
  572. window = wx.Dialog(parent=None)
  573. panel = LocationDownloadPanel(parent=window, database=database)
  574. window.ShowModal()
  575. location = panel.GetLocation()
  576. if location:
  577. print(location)
  578. window.Destroy()
  579. else:
  580. print("Unknown settings: try dialog or panel")
  581. app.MainLoop()
  582. if __name__ == "__main__":
  583. main()