g.extension.py 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690
  1. #!/usr/bin/env python
  2. ############################################################################
  3. #
  4. # MODULE: g.extension
  5. # AUTHOR(S): Markus Neteler (original shell script)
  6. # Martin Landa <landa.martin gmail com> (Pythonized & upgraded for GRASS 7)
  7. # Vaclav Petras <wenzeslaus gmail com> (support for general sources)
  8. # PURPOSE: Tool to download and install extensions into local installation
  9. #
  10. # COPYRIGHT: (C) 2009-2014 by Markus Neteler, and the GRASS Development Team
  11. #
  12. # This program is free software under the GNU General
  13. # Public License (>=v2). Read the file COPYING that
  14. # comes with GRASS for details.
  15. #
  16. # TODO: add sudo support where needed (i.e. check first permission to write into
  17. # $GISBASE directory)
  18. #############################################################################
  19. #%module
  20. #% label: Maintains GRASS Addons extensions in local GRASS installation.
  21. #% description: Downloads and installs extensions from GRASS Addons repository or other source into the local GRASS installation or removes installed extensions.
  22. #% keyword: general
  23. #% keyword: installation
  24. #% keyword: extensions
  25. #% keyword: addons
  26. #% keyword: download
  27. #%end
  28. #%option
  29. #% key: extension
  30. #% type: string
  31. #% key_desc: name
  32. #% label: Name of extension to install or remove
  33. #% description: Name of toolbox (set of extensions) when -t flag is given
  34. #% required: yes
  35. #%end
  36. #%option
  37. #% key: operation
  38. #% type: string
  39. #% description: Operation to be performed
  40. #% required: yes
  41. #% options: add,remove
  42. #% answer: add
  43. #%end
  44. #%option
  45. #% key: url
  46. #% type: string
  47. #% key_desc: url
  48. #% label: URL or directory to get the extension from (supported only on Linux and Mac)
  49. #% description: The official repository is used by default. User can specify a ZIP file, directory or a repository on common hosting services. If not identified, Subversion repository is assumed. See manual for all options.
  50. #%end
  51. #%option
  52. #% key: prefix
  53. #% type: string
  54. #% key_desc: path
  55. #% description: Prefix where to install extension (ignored when flag -s is given)
  56. #% answer: $GRASS_ADDON_BASE
  57. #% required: no
  58. #%end
  59. #%option
  60. #% key: proxy
  61. #% type: string
  62. #% key_desc: proxy
  63. #% description: Set the proxy with: "http=<value>,ftp=<value>"
  64. #% required: no
  65. #% multiple: yes
  66. #%end
  67. #%flag
  68. #% key: l
  69. #% description: List available extensions in the official GRASS GIS Addons repository
  70. #% guisection: Print
  71. #% suppress_required: yes
  72. #%end
  73. #%flag
  74. #% key: c
  75. #% description: List available extensions in the official GRASS GIS Addons repository including module description
  76. #% guisection: Print
  77. #% suppress_required: yes
  78. #%end
  79. #%flag
  80. #% key: g
  81. #% description: List available extensions in the official GRASS GIS Addons repository (shell script style)
  82. #% guisection: Print
  83. #% suppress_required: yes
  84. #%end
  85. #%flag
  86. #% key: a
  87. #% description: List locally installed extensions
  88. #% guisection: Print
  89. #% suppress_required: yes
  90. #%end
  91. #%flag
  92. #% key: s
  93. #% description: Install system-wide (may need system administrator rights)
  94. #% guisection: Install
  95. #%end
  96. #%flag
  97. #% key: d
  98. #% description: Download source code and exit
  99. #% guisection: Install
  100. #%end
  101. #%flag
  102. #% key: i
  103. #% description: Do not install new extension, just compile it
  104. #% guisection: Install
  105. #%end
  106. #%flag
  107. #% key: f
  108. #% description: Force removal when uninstalling extension (operation=remove)
  109. #% guisection: Remove
  110. #%end
  111. #%flag
  112. #% key: t
  113. #% description: Operate on toolboxes instead of single modules (experimental)
  114. #% suppress_required: yes
  115. #%end
  116. #%rules
  117. #% required: extension, -l, -c, -g, -a
  118. #% exclusive: extension, -l, -c, -g, -a
  119. #%end
  120. # TODO: solve addon-extension(-module) confusion
  121. from __future__ import print_function
  122. import os
  123. import sys
  124. import re
  125. import atexit
  126. import shutil
  127. import zipfile
  128. import tempfile
  129. try:
  130. from urllib2 import HTTPError, URLError
  131. from urllib import urlopen, urlretrieve
  132. except ImportError:
  133. # there is also HTTPException, perhaps change to list
  134. from urllib.request import HTTPError, URLError, \
  135. urlopen, urlretrieve # pylint: disable=E0611
  136. try:
  137. import xml.etree.ElementTree as etree
  138. except ImportError:
  139. import elementtree.ElementTree as etree # Python <= 2.4
  140. # Get the XML parsing exceptions to catch. The behavior changed with Python 2.7
  141. # and ElementTree 1.3.
  142. from xml.parsers import expat # TODO: works for any Python?
  143. if hasattr(etree, 'ParseError'):
  144. ETREE_EXCEPTIONS = (etree.ParseError, expat.ExpatError)
  145. else:
  146. ETREE_EXCEPTIONS = (expat.ExpatError)
  147. import grass.script as gscript
  148. from grass.script.utils import try_rmdir
  149. from grass.script import core as grass
  150. # temp dir
  151. REMOVE_TMPDIR = True
  152. PROXIES = {}
  153. def etree_fromfile(filename):
  154. """Create XML element tree from a given file name"""
  155. with open(filename, 'r') as file_:
  156. return etree.fromstring(file_.read())
  157. def etree_fromurl(url, proxies=None):
  158. """Create XML element tree from a given URL"""
  159. file_ = urlopen(url, proxies=proxies)
  160. return etree.fromstring(file_.read())
  161. def check_progs():
  162. """Check if the necessary programs are available"""
  163. # TODO: we need svn for the Subversion repo downloads
  164. # also git would be tested once supported
  165. for prog in ('make', 'gcc'):
  166. if not grass.find_program(prog, '--help'):
  167. grass.fatal(_("'%s' required. Please install '%s' first.")
  168. % (prog, prog))
  169. # expand prefix to class name
  170. def expand_module_class_name(class_letters):
  171. """Convert module class (family) letter or letters to class (family) name
  172. The letter or letters are used in module names, e.g. r.slope.aspect.
  173. The names are used in directories in Addons but also in the source code.
  174. >>> expand_module_class_name('r')
  175. 'raster'
  176. >>> expand_module_class_name('v')
  177. 'vector'
  178. """
  179. name = {
  180. 'd': 'display',
  181. 'db': 'database',
  182. 'g': 'general',
  183. 'i': 'imagery',
  184. 'm': 'misc',
  185. 'ps': 'postscript',
  186. 'p': 'paint',
  187. 'r': 'raster',
  188. 'r3': 'raster3d',
  189. 's': 'sites',
  190. 'v': 'vector',
  191. 'wx': 'gui/wxpython'
  192. }
  193. return name.get(class_letters, class_letters)
  194. def get_module_class_name(module_name):
  195. """Return class (family) name for a module
  196. The names are used in directories in Addons but also in the source code.
  197. >>> get_module_class_name('r.slope.aspect')
  198. 'raster'
  199. >>> get_module_class_name('v.to.rast')
  200. 'vector'
  201. """
  202. classchar = module_name.split('.', 1)[0]
  203. return expand_module_class_name(classchar)
  204. def get_installed_extensions(force=False):
  205. """Get list of installed extensions or toolboxes (if -t is set)"""
  206. if flags['t']:
  207. return get_installed_toolboxes(force)
  208. return get_installed_modules(force)
  209. def list_installed_extensions(toolboxes=False):
  210. """List installed extensions"""
  211. elist = get_installed_extensions()
  212. if elist:
  213. if toolboxes:
  214. grass.message(_("List of installed extensions (toolboxes):"))
  215. else:
  216. grass.message(_("List of installed extensions (modules):"))
  217. sys.stdout.write('\n'.join(elist))
  218. sys.stdout.write('\n')
  219. else:
  220. if toolboxes:
  221. grass.info(_("No extension (toolbox) installed"))
  222. else:
  223. grass.info(_("No extension (module) installed"))
  224. def get_installed_toolboxes(force=False):
  225. """Get list of installed toolboxes
  226. Writes toolboxes file if it does not exist.
  227. Creates a new toolboxes file if it is not possible
  228. to read the current one.
  229. """
  230. xml_file = os.path.join(options['prefix'], 'toolboxes.xml')
  231. if not os.path.exists(xml_file):
  232. write_xml_toolboxes(xml_file)
  233. # read XML file
  234. try:
  235. tree = etree_fromfile(xml_file)
  236. except ETREE_EXCEPTIONS + (OSError, IOError):
  237. os.remove(xml_file)
  238. write_xml_toolboxes(xml_file)
  239. return []
  240. ret = list()
  241. for tnode in tree.findall('toolbox'):
  242. ret.append(tnode.get('code'))
  243. return ret
  244. def get_installed_modules(force=False):
  245. """Get list of installed modules.
  246. Writes modules file if it does not exist and *force* is set to ``True``.
  247. Creates a new modules file if it is not possible
  248. to read the current one.
  249. """
  250. xml_file = os.path.join(options['prefix'], 'modules.xml')
  251. if not os.path.exists(xml_file):
  252. if force:
  253. write_xml_modules(xml_file)
  254. else:
  255. grass.debug(1, "No addons metadata file available")
  256. return []
  257. # read XML file
  258. try:
  259. tree = etree_fromfile(xml_file)
  260. except ETREE_EXCEPTIONS + (OSError, IOError):
  261. os.remove(xml_file)
  262. write_xml_modules(xml_file)
  263. return []
  264. ret = list()
  265. for tnode in tree.findall('task'):
  266. ret.append(tnode.get('name').strip())
  267. return ret
  268. # list extensions (read XML file from grass.osgeo.org/addons)
  269. def list_available_extensions(url):
  270. """List available extensions/modules or toolboxes (if -t is given)
  271. For toolboxes it lists also all modules.
  272. """
  273. gscript.debug("list_available_extensions(url={})".format(url))
  274. if flags['t']:
  275. grass.message(_("List of available extensions (toolboxes):"))
  276. tlist = get_available_toolboxes(url)
  277. for toolbox_code, toolbox_data in tlist.iteritems():
  278. if flags['g']:
  279. print('toolbox_name=' + toolbox_data['name'])
  280. print('toolbox_code=' + toolbox_code)
  281. else:
  282. print('%s (%s)' % (toolbox_data['name'], toolbox_code))
  283. if flags['c'] or flags['g']:
  284. list_available_modules(url, toolbox_data['modules'])
  285. else:
  286. if toolbox_data['modules']:
  287. print(os.linesep.join(map(lambda x: '* ' + x,
  288. toolbox_data['modules'])))
  289. else:
  290. grass.message(_("List of available extensions (modules):"))
  291. list_available_modules(url)
  292. def get_available_toolboxes(url):
  293. """Return toolboxes available in the repository"""
  294. tdict = dict()
  295. url = url + "toolboxes.xml"
  296. try:
  297. tree = etree_fromurl(url, proxies=PROXIES)
  298. for tnode in tree.findall('toolbox'):
  299. mlist = list()
  300. clist = list()
  301. tdict[tnode.get('code')] = {'name': tnode.get('name'),
  302. 'correlate': clist,
  303. 'modules': mlist}
  304. for cnode in tnode.findall('correlate'):
  305. clist.append(cnode.get('name'))
  306. for mnode in tnode.findall('task'):
  307. mlist.append(mnode.get('name'))
  308. except (HTTPError, IOError, OSError):
  309. grass.fatal(_("Unable to fetch addons metadata file"))
  310. return tdict
  311. def get_toolbox_modules(url, name):
  312. """Get modules inside a toolbox in toolbox file at given URL
  313. :param url: URL of the directory (file name will be attached)
  314. :param name: toolbox name
  315. """
  316. tlist = list()
  317. url = url + "toolboxes.xml"
  318. try:
  319. tree = etree_fromurl(url, proxies=PROXIES)
  320. for tnode in tree.findall('toolbox'):
  321. if name == tnode.get('code'):
  322. for mnode in tnode.findall('task'):
  323. tlist.append(mnode.get('name'))
  324. break
  325. except (HTTPError, IOError, OSError):
  326. grass.fatal(_("Unable to fetch addons metadata file"))
  327. return tlist
  328. def get_optional_params(mnode):
  329. """Return description and keywords as a tuple
  330. :param mnode: XML node for a module
  331. """
  332. try:
  333. desc = mnode.find('description').text
  334. except AttributeError:
  335. desc = ''
  336. if desc is None:
  337. desc = ''
  338. try:
  339. keyw = mnode.find('keywords').text
  340. except AttributeError:
  341. keyw = ''
  342. if keyw is None:
  343. keyw = ''
  344. return desc, keyw
  345. def list_available_modules(url, mlist=None):
  346. """List modules available in the repository
  347. Tries to use XML metadata file first. Fallbacks to HTML page with a list.
  348. :param url: URL of the directory (file name will be attached)
  349. :param mlist: list only modules in this list
  350. """
  351. file_url = url + "modules.xml"
  352. grass.debug("url=%s" % file_url, 1)
  353. try:
  354. tree = etree_fromurl(file_url, proxies=PROXIES)
  355. except ETREE_EXCEPTIONS:
  356. grass.warning(_("Unable to parse '%s'. Trying to scan"
  357. " SVN repository (may take some time)...") % file_url)
  358. list_available_extensions_svn(url)
  359. return
  360. except (HTTPError, URLError, IOError, OSError):
  361. list_available_extensions_svn(url)
  362. return
  363. for mnode in tree.findall('task'):
  364. name = mnode.get('name').strip()
  365. if mlist and name not in mlist:
  366. continue
  367. if flags['c'] or flags['g']:
  368. desc, keyw = get_optional_params(mnode)
  369. if flags['g']:
  370. print('name=' + name)
  371. print('description=' + desc)
  372. print('keywords=' + keyw)
  373. elif flags['c']:
  374. if mlist:
  375. print('*', end='')
  376. print(name + ' - ' + desc)
  377. else:
  378. print(name)
  379. # TODO: this is now broken/dead code, SVN is basically not used
  380. # fallback for Trac should parse Trac HTML page
  381. # this might be useful for potential SVN repos or anything
  382. # which would list the extensions/addons as list
  383. # TODO: fail when nothing is accessible
  384. def list_available_extensions_svn(url):
  385. """List available extensions from HTML given by URL
  386. Filename is generated based on the module class/family.
  387. This works well for the structure which is in grass-addons repository.
  388. ``<li><a href=...`` is parsed to find module names.
  389. This works well for HTML page generated by Subversion.
  390. :param url: a directory URL (filename will be attached)
  391. """
  392. gscript.debug("list_available_extensions_svn(url=%s)" % url, 2)
  393. grass.message(_('Fetching list of extensions from'
  394. ' GRASS-Addons SVN repository (be patient)...'))
  395. pattern = re.compile(r'(<li><a href=".+">)(.+)(</a></li>)', re.IGNORECASE)
  396. if flags['c']:
  397. grass.warning(
  398. _("Flag 'c' ignored, addons metadata file not available"))
  399. if flags['g']:
  400. grass.warning(
  401. _("Flag 'g' ignored, addons metadata file not available"))
  402. prefixes = ['d', 'db', 'g', 'i', 'm', 'ps',
  403. 'p', 'r', 'r3', 's', 'v']
  404. for prefix in prefixes:
  405. modclass = expand_module_class_name(prefix)
  406. grass.verbose(_("Checking for '%s' modules...") % modclass)
  407. # construct a full URL of a file
  408. file_url = '%s/%s' % (url, modclass)
  409. grass.debug("url = %s" % file_url, debug=2)
  410. try:
  411. file_ = urlopen(file_url, proxies=PROXIES)
  412. except (HTTPError, IOError, OSError):
  413. grass.debug(_("Unable to fetch '%s'") % file_url, debug=1)
  414. continue
  415. for line in file_.readlines():
  416. # list extensions
  417. sline = pattern.search(line)
  418. if not sline:
  419. continue
  420. name = sline.group(2).rstrip('/')
  421. if name.split('.', 1)[0] == prefix:
  422. print(name)
  423. # get_wxgui_extensions(url)
  424. # TODO: this is a dead code, not clear why not used, but seems not needed
  425. def get_wxgui_extensions(url):
  426. """Return list of extensions/addons in wxGUI directory at given URL
  427. :param url: a directory URL (filename will be attached)
  428. """
  429. mlist = list()
  430. grass.debug('Fetching list of wxGUI extensions from '
  431. 'GRASS-Addons SVN repository (be patient)...')
  432. pattern = re.compile(r'(<li><a href=".+">)(.+)(</a></li>)', re.IGNORECASE)
  433. grass.verbose(_("Checking for '%s' modules...") % 'gui/wxpython')
  434. # construct a full URL of a file
  435. url = '%s/%s' % (url, 'gui/wxpython')
  436. grass.debug("url = %s" % url, debug=2)
  437. file_ = urlopen(url, proxies=PROXIES)
  438. if not file_:
  439. grass.warning(_("Unable to fetch '%s'") % url)
  440. return
  441. for line in file.readlines():
  442. # list extensions
  443. sline = pattern.search(line)
  444. if not sline:
  445. continue
  446. name = sline.group(2).rstrip('/')
  447. if name not in ('..', 'Makefile'):
  448. mlist.append(name)
  449. return mlist
  450. def cleanup():
  451. """Cleanup after the downloads and copilation"""
  452. if REMOVE_TMPDIR:
  453. try_rmdir(TMPDIR)
  454. else:
  455. grass.message("\n%s\n" % _("Path to the source code:"))
  456. sys.stderr.write('%s\n' % os.path.join(TMPDIR, options['extension']))
  457. def write_xml_modules(name, tree=None):
  458. """Write element tree as a modules matadata file
  459. If the *tree* is not given, an empty file is created.
  460. :param name: file name
  461. :param tree: XML element tree
  462. """
  463. file_ = open(name, 'w')
  464. file_.write('<?xml version="1.0" encoding="UTF-8"?>\n')
  465. file_.write('<!DOCTYPE task SYSTEM "grass-addons.dtd">\n')
  466. file_.write('<addons version="%s">\n' % version[0])
  467. libgis_revison = grass.version()['libgis_revision']
  468. if tree is not None:
  469. for tnode in tree.findall('task'):
  470. indent = 4
  471. file_.write('%s<task name="%s">\n' %
  472. (' ' * indent, tnode.get('name')))
  473. indent += 4
  474. file_.write('%s<description>%s</description>\n' %
  475. (' ' * indent, tnode.find('description').text))
  476. file_.write('%s<keywords>%s</keywords>\n' %
  477. (' ' * indent, tnode.find('keywords').text))
  478. bnode = tnode.find('binary')
  479. if bnode is not None:
  480. file_.write('%s<binary>\n' % (' ' * indent))
  481. indent += 4
  482. for fnode in bnode.findall('file'):
  483. file_.write('%s<file>%s</file>\n' %
  484. (' ' * indent, os.path.join(options['prefix'],
  485. fnode.text)))
  486. indent -= 4
  487. file_.write('%s</binary>\n' % (' ' * indent))
  488. file_.write('%s<libgis revision="%s" />\n' %
  489. (' ' * indent, libgis_revison))
  490. indent -= 4
  491. file_.write('%s</task>\n' % (' ' * indent))
  492. file_.write('</addons>\n')
  493. file_.close()
  494. def write_xml_toolboxes(name, tree=None):
  495. """Write element tree as a toolboxes matadata file
  496. If the *tree* is not given, an empty file is created.
  497. :param name: file name
  498. :param tree: XML element tree
  499. """
  500. file_ = open(name, 'w')
  501. file_.write('<?xml version="1.0" encoding="UTF-8"?>\n')
  502. file_.write('<!DOCTYPE toolbox SYSTEM "grass-addons.dtd">\n')
  503. file_.write('<addons version="%s">\n' % version[0])
  504. if tree is not None:
  505. for tnode in tree.findall('toolbox'):
  506. indent = 4
  507. file_.write('%s<toolbox name="%s" code="%s">\n' %
  508. (' ' * indent, tnode.get('name'), tnode.get('code')))
  509. indent += 4
  510. for cnode in tnode.findall('correlate'):
  511. file_.write('%s<correlate code="%s" />\n' %
  512. (' ' * indent, tnode.get('code')))
  513. for mnode in tnode.findall('task'):
  514. file_.write('%s<task name="%s" />\n' %
  515. (' ' * indent, mnode.get('name')))
  516. indent -= 4
  517. file_.write('%s</toolbox>\n' % (' ' * indent))
  518. file_.write('</addons>\n')
  519. file_.close()
  520. def install_extension(source, url, xmlurl):
  521. """Install extension (e.g. one module) or a toolbox (list of modules)"""
  522. gisbase = os.getenv('GISBASE')
  523. if not gisbase:
  524. grass.fatal(_('$GISBASE not defined'))
  525. if options['extension'] in get_installed_extensions(force=True):
  526. grass.warning(_("Extension <%s> already installed. Re-installing...") %
  527. options['extension'])
  528. if flags['t']:
  529. grass.message(_("Installing toolbox <%s>...") % options['extension'])
  530. mlist = get_toolbox_modules(xmlurl, options['extension'])
  531. else:
  532. mlist = [options['extension']]
  533. if not mlist:
  534. grass.warning(_("Nothing to install"))
  535. return
  536. ret = 0
  537. for module in mlist:
  538. if sys.platform == "win32":
  539. ret += install_extension_win(module)
  540. else:
  541. ret += install_extension_std_platforms(module,
  542. source=source, url=url)
  543. if len(mlist) > 1:
  544. print('-' * 60)
  545. if flags['d']:
  546. return
  547. if ret != 0:
  548. grass.warning(_('Installation failed, sorry.'
  549. ' Please check above error messages.'))
  550. else:
  551. # for now it is reasonable to assume that only official source
  552. # will provide the metadata file
  553. if source == 'official':
  554. grass.message(_("Updating addons metadata file..."))
  555. blist = install_extension_xml(xmlurl, mlist)
  556. # the blist was used here, but it seems that it is the same as mlist
  557. for module in mlist:
  558. update_manual_page(module)
  559. grass.message(_("Installation of <%s> successfully finished") %
  560. options['extension'])
  561. if not os.getenv('GRASS_ADDON_BASE'):
  562. grass.warning(_('This add-on module will not function until'
  563. ' you set the GRASS_ADDON_BASE environment'
  564. ' variable (see "g.manual variables")'))
  565. def get_toolboxes_metadata(url):
  566. """Return metadata for all toolboxes from given URL
  567. :param url: URL of a modules matadata file
  568. :param mlist: list of modules to get metadata for
  569. :returns: tuple where first item is dictionary with module names as keys
  570. and dictionary with dest, keyw, files keys as value, the second item
  571. is list of 'binary' files (installation files)
  572. """
  573. data = dict()
  574. try:
  575. tree = etree_fromurl(url, proxies=PROXIES)
  576. for tnode in tree.findall('toolbox'):
  577. clist = list()
  578. for cnode in tnode.findall('correlate'):
  579. clist.append(cnode.get('code'))
  580. mlist = list()
  581. for mnode in tnode.findall('task'):
  582. mlist.append(mnode.get('name'))
  583. code = tnode.get('code')
  584. data[code] = {
  585. 'name': tnode.get('name'),
  586. 'correlate': clist,
  587. 'modules': mlist,
  588. }
  589. except (HTTPError, IOError, OSError):
  590. grass.error(_("Unable to read addons metadata file "
  591. "from the remote server"))
  592. return data
  593. def install_toolbox_xml(url, name):
  594. """Update local toolboxes metadata file"""
  595. # read metadata from remote server (toolboxes)
  596. url = url + "toolboxes.xml"
  597. data = get_toolboxes_metadata(url)
  598. if not data:
  599. grass.warning(_("No addons metadata available"))
  600. return
  601. if name not in data:
  602. grass.warning(_("No addons metadata available for <%s>") % name)
  603. return
  604. xml_file = os.path.join(options['prefix'], 'toolboxes.xml')
  605. # create an empty file if not exists
  606. if not os.path.exists(xml_file):
  607. write_xml_modules(xml_file)
  608. # read XML file
  609. with open(xml_file, 'r') as xml:
  610. tree = etree.fromstring(xml.read())
  611. # update tree
  612. tnode = None
  613. for node in tree.findall('toolbox'):
  614. if node.get('code') == name:
  615. tnode = node
  616. break
  617. tdata = data[name]
  618. if tnode is not None:
  619. # update existing node
  620. for cnode in tnode.findall('correlate'):
  621. tnode.remove(cnode)
  622. for mnode in tnode.findall('task'):
  623. tnode.remove(mnode)
  624. else:
  625. # create new node for task
  626. tnode = etree.Element(
  627. 'toolbox', attrib={'name': tdata['name'], 'code': name})
  628. tree.append(tnode)
  629. for cname in tdata['correlate']:
  630. cnode = etree.Element('correlate', attrib={'code': cname})
  631. tnode.append(cnode)
  632. for tname in tdata['modules']:
  633. mnode = etree.Element('task', attrib={'name': tname})
  634. tnode.append(mnode)
  635. write_xml_toolboxes(xml_file, tree)
  636. def get_addons_metadata(url, mlist):
  637. """Return metadata for list of modules from given URL
  638. :param url: URL of a modules matadata file
  639. :param mlist: list of modules to get metadata for
  640. :returns: tuple where first item is dictionary with module names as keys
  641. and dictionary with dest, keyw, files keys as value, the second item
  642. is list of 'binary' files (installation files)
  643. """
  644. data = {}
  645. bin_list = []
  646. try:
  647. tree = etree_fromurl(url, proxies=PROXIES)
  648. except (HTTPError, URLError, IOError, OSError) as error:
  649. grass.error(_("Unable to read addons metadata file"
  650. " from the remote server: {}").format(error))
  651. return data, bin_list
  652. except ETREE_EXCEPTIONS as error:
  653. grass.warning(_("Unable to parse '%s': {}").format(error) % url)
  654. return data, bin_list
  655. for mnode in tree.findall('task'):
  656. name = mnode.get('name')
  657. if name not in mlist:
  658. continue
  659. file_list = list()
  660. bnode = mnode.find('binary')
  661. windows = sys.platform == 'win32'
  662. if bnode is not None:
  663. for fnode in bnode.findall('file'):
  664. path = fnode.text.split('/')
  665. if path[0] == 'bin':
  666. bin_list.append(path[-1])
  667. if windows:
  668. path[-1] += '.exe'
  669. elif path[0] == 'scripts':
  670. bin_list.append(path[-1])
  671. if windows:
  672. path[-1] += '.py'
  673. file_list.append(os.path.sep.join(path))
  674. desc, keyw = get_optional_params(mnode)
  675. data[name] = {
  676. 'desc': desc,
  677. 'keyw': keyw,
  678. 'files': file_list,
  679. }
  680. return data, bin_list
  681. def install_extension_xml(url, mlist):
  682. """Update XML files with metadata about installed modules and toolbox
  683. Uses the remote/repository XML files for modules to obtain the metadata.
  684. :returns: list of executables (useable for ``update_manual_page()``)
  685. """
  686. if len(mlist) > 1:
  687. # read metadata from remote server (toolboxes)
  688. install_toolbox_xml(url, options['extension'])
  689. # read metadata from remote server (modules)
  690. url = url + "modules.xml"
  691. data, bin_list = get_addons_metadata(url, mlist)
  692. if not data:
  693. grass.warning(_("No addons metadata available."
  694. " Addons metadata file not updated."))
  695. return []
  696. xml_file = os.path.join(options['prefix'], 'modules.xml')
  697. # create an empty file if not exists
  698. if not os.path.exists(xml_file):
  699. write_xml_modules(xml_file)
  700. # read XML file
  701. tree = etree_fromfile(xml_file)
  702. # update tree
  703. for name in mlist:
  704. tnode = None
  705. for node in tree.findall('task'):
  706. if node.get('name') == name:
  707. tnode = node
  708. break
  709. if name not in data:
  710. grass.warning(_("No addons metadata found for <%s>") % name)
  711. continue
  712. ndata = data[name]
  713. if tnode is not None:
  714. # update existing node
  715. dnode = tnode.find('description')
  716. if dnode is not None:
  717. dnode.text = ndata['desc']
  718. knode = tnode.find('keywords')
  719. if knode is not None:
  720. knode.text = ndata['keyw']
  721. bnode = tnode.find('binary')
  722. if bnode is not None:
  723. tnode.remove(bnode)
  724. bnode = etree.Element('binary')
  725. for file_name in ndata['files']:
  726. fnode = etree.Element('file')
  727. fnode.text = file_name
  728. bnode.append(fnode)
  729. tnode.append(bnode)
  730. else:
  731. # create new node for task
  732. tnode = etree.Element('task', attrib={'name': name})
  733. dnode = etree.Element('description')
  734. dnode.text = ndata['desc']
  735. tnode.append(dnode)
  736. knode = etree.Element('keywords')
  737. knode.text = ndata['keyw']
  738. tnode.append(knode)
  739. bnode = etree.Element('binary')
  740. for file_name in ndata['files']:
  741. fnode = etree.Element('file')
  742. fnode.text = file_name
  743. bnode.append(fnode)
  744. tnode.append(bnode)
  745. tree.append(tnode)
  746. write_xml_modules(xml_file, tree)
  747. return bin_list
  748. def install_extension_win(name):
  749. """Install extension on MS Windows"""
  750. # do not use hardcoded url -
  751. # http://wingrass.fsv.cvut.cz/grassXX/addonsX.X.X
  752. grass.message(_("Downloading precompiled GRASS Addons <%s>...") %
  753. options['extension'])
  754. url = "http://wingrass.fsv.cvut.cz/" \
  755. "grass%(major)s%(minor)s/addons/" \
  756. "grass-%(major)s.%(minor)s.%(patch)s/" % \
  757. {'major': version[0], 'minor': version[1], 'patch': version[2]}
  758. grass.debug("url=%s" % url, 1)
  759. try:
  760. zfile = url + name + '.zip'
  761. url_file = urlopen(zfile, proxies=PROXIES)
  762. # create addons dir if not exists
  763. if not os.path.exists(options['prefix']):
  764. try:
  765. os.mkdir(options['prefix'])
  766. except OSError as error:
  767. grass.fatal(_("Unable to create <{}>. {}")
  768. .format(options['prefix'], error))
  769. # download data
  770. tmp_file = tempfile.TemporaryFile()
  771. tmp_file.write(url_file.read())
  772. try:
  773. zfobj = zipfile.ZipFile(tmp_file)
  774. except zipfile.BadZipfile as error:
  775. grass.fatal('%s: %s' % (error, zfile))
  776. for name in zfobj.namelist():
  777. if name.endswith('/'):
  778. directory = os.path.join(options['prefix'], name)
  779. if not os.path.exists(directory):
  780. os.mkdir(directory)
  781. else:
  782. outfile = open(os.path.join(options['prefix'], name), 'wb')
  783. outfile.write(zfobj.read(name))
  784. outfile.close()
  785. tmp_file.close()
  786. except HTTPError:
  787. grass.fatal(_("GRASS Addons <%s> not found") % name)
  788. return 0
  789. def download_source_code_svn(url, name, outdev, directory=None):
  790. """Download source code from a Subversion reporsitory
  791. .. note:
  792. Stdout is passed to to *outdev* while stderr is will be just printed.
  793. :param url: URL of the repository
  794. (module class/family and name are attached)
  795. :param name: module name
  796. :param outdev: output devide for the standard output of the svn command
  797. :param directory: directory where the source code will be downloaded
  798. (default is the current directory with name attached)
  799. :returns: full path to the directory with the source code
  800. (useful when you not specify directory, if *directory* is specified
  801. the return value is equal to it)
  802. """
  803. if not directory:
  804. directory = os.path.join(os.getcwd, name)
  805. classchar = name.split('.', 1)[0]
  806. moduleclass = expand_module_class_name(classchar)
  807. url = url + '/' + moduleclass + '/' + name
  808. if grass.call(['svn', 'checkout',
  809. url, directory], stdout=outdev) != 0:
  810. grass.fatal(_("GRASS Addons <%s> not found") % name)
  811. return directory
  812. def move_extracted_files(extract_dir, target_dir, files):
  813. """Fix state of extracted file by moving them to different diretcory
  814. When extracting, it is not clear what will be the root directory
  815. or if there will be one at all. So this function moves the files to
  816. a different directory in the way that if there was one direcory extracted,
  817. the contained files are moved.
  818. """
  819. gscript.debug("move_extracted_files({})".format(locals()))
  820. if len(files) == 1:
  821. shutil.copytree(os.path.join(extract_dir, files[0]), target_dir)
  822. else:
  823. os.mkdir(target_dir)
  824. for file_name in files:
  825. actual_file = os.path.join(extract_dir, file_name)
  826. if os.path.isdir(actual_file):
  827. shutil.copytree(actual_file,
  828. os.path.join(target_dir, file_name))
  829. else:
  830. shutil.copy(actual_file, os.path.join(target_dir, file_name))
  831. # Original copyright and license of the original version of the CRLF function
  832. # Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010
  833. # Python Software Foundation; All Rights Reserved
  834. # Python Software Foundation License Version 2
  835. # http://svn.python.org/projects/python/trunk/Tools/scripts/crlf.py
  836. def fix_newlines(directory):
  837. """Replace CRLF with LF in all files in the directory
  838. Binary files are ignored. Recurses into subdirectories.
  839. """
  840. for root, unused, files in os.walk(directory):
  841. for name in files:
  842. filename = os.path.join(root, name)
  843. data = open(filename, 'rb').read()
  844. if '\0' in data:
  845. continue # ignore binary files
  846. # we don't expect there would be CRLF file by purpose
  847. # if we want to allow CRLF files we would have to whitelite .py etc
  848. newdata = data.replace('\r\n', '\n')
  849. if newdata != data:
  850. newfile = open(filename, 'wb')
  851. newfile.write(newdata)
  852. newfile.close()
  853. def extract_zip(name, directory, tmpdir):
  854. """Extract a ZIP file into a directory"""
  855. gscript.debug("extract_zip(name={name}, directory={directory},"
  856. " tmpdir={tmpdir})".format(name=name, directory=directory,
  857. tmpdir=tmpdir), 3)
  858. try:
  859. zip_file = zipfile.ZipFile(name, mode='r')
  860. file_list = zip_file.namelist()
  861. # we suppose we can write to parent of the given dir
  862. # (supposing a tmp dir)
  863. extract_dir = os.path.join(tmpdir, 'extract_dir')
  864. os.mkdir(extract_dir)
  865. for subfile in file_list:
  866. # this should be safe in Python 2.7.4
  867. zip_file.extract(subfile, extract_dir)
  868. files = os.listdir(extract_dir)
  869. move_extracted_files(extract_dir=extract_dir,
  870. target_dir=directory, files=files)
  871. except zipfile.BadZipfile as error:
  872. gscript.fatal(_("ZIP file is unreadable: {}").format(error))
  873. # TODO: solve the other related formats
  874. def extract_tar(name, directory, tmpdir):
  875. """Extract a TAR or a similar file into a directory"""
  876. gscript.debug("extract_tar(name={name}, directory={directory},"
  877. " tmpdir={tmpdir})".format(name=name, directory=directory,
  878. tmpdir=tmpdir), 3)
  879. try:
  880. import tarfile # we don't need it anywhere else
  881. tar = tarfile.open(name)
  882. extract_dir = os.path.join(tmpdir, 'extract_dir')
  883. os.mkdir(extract_dir)
  884. tar.extractall(path=extract_dir)
  885. files = os.listdir(extract_dir)
  886. move_extracted_files(extract_dir=extract_dir,
  887. target_dir=directory, files=files)
  888. except tarfile.TarError as error:
  889. gscript.fatal(_("Archive file is unreadable: {}").format(error))
  890. extract_tar.supported_formats = ['tar.gz', 'gz', 'bz2', 'tar', 'gzip', 'targz']
  891. def download_source_code(source, url, name, outdev,
  892. directory=None, tmpdir=None):
  893. """Get source code to a local directory for compilation"""
  894. gscript.verbose("Downloading source code for <{name}> from <{url}>"
  895. " which is identified as '{source}' type of source..."
  896. .format(source=source, url=url, name=name))
  897. if source == 'svn':
  898. download_source_code_svn(url, name, outdev, directory)
  899. elif source in ['remote_zip', 'official']:
  900. # we expect that the module.zip file is not by chance in the archive
  901. zip_name = os.path.join(tmpdir, 'extension.zip')
  902. urlretrieve(url, zip_name)
  903. extract_zip(name=zip_name, directory=directory, tmpdir=tmpdir)
  904. fix_newlines(directory)
  905. elif source.startswith('remote_') and \
  906. source.split('_')[1] in extract_tar.supported_formats:
  907. # we expect that the module.tar.gz file is not by chance in the archive
  908. archive_name = os.path.join(tmpdir,
  909. 'extension.' + source.split('_')[1])
  910. urlretrieve(url, archive_name)
  911. extract_tar(name=archive_name, directory=directory, tmpdir=tmpdir)
  912. fix_newlines(directory)
  913. elif source == 'zip':
  914. extract_zip(name=url, directory=directory, tmpdir=tmpdir)
  915. fix_newlines(directory)
  916. elif source in extract_tar.supported_formats:
  917. extract_tar(name=url, directory=directory, tmpdir=tmpdir)
  918. fix_newlines(directory)
  919. elif source == 'dir':
  920. shutil.copytree(url, directory)
  921. fix_newlines(directory)
  922. else:
  923. # probably programmer error
  924. grass.fatal(_("Unknown extension (addon) source type '{}'."
  925. " Please report this to the grass-user mailing list.")
  926. .format(source))
  927. assert os.path.isdir(directory)
  928. def install_extension_std_platforms(name, source, url):
  929. """Install extension on standard plaforms"""
  930. gisbase = os.getenv('GISBASE')
  931. if source == 'official':
  932. gscript.message(_("Fetching <%s> from "
  933. "GRASS GIS Addons repository (be patient)...") % name)
  934. else:
  935. gscript.message(_("Fetching <{name}> from "
  936. "<{url}> (be patient)...").format(name=name, url=url))
  937. # to hide non-error messages from subprocesses
  938. if grass.verbosity() <= 2:
  939. outdev = open(os.devnull, 'w')
  940. else:
  941. outdev = sys.stdout
  942. os.chdir(TMPDIR) # this is just to not leave something behind
  943. srcdir = os.path.join(TMPDIR, name)
  944. download_source_code(source=source, url=url, name=name,
  945. outdev=outdev, directory=srcdir, tmpdir=TMPDIR)
  946. os.chdir(srcdir)
  947. dirs = {
  948. 'bin': os.path.join(TMPDIR, name, 'bin'),
  949. 'docs': os.path.join(TMPDIR, name, 'docs'),
  950. 'html': os.path.join(TMPDIR, name, 'docs', 'html'),
  951. 'rest': os.path.join(TMPDIR, name, 'docs', 'rest'),
  952. 'man': os.path.join(TMPDIR, name, 'docs', 'man'),
  953. 'script': os.path.join(TMPDIR, name, 'scripts'),
  954. # TODO: handle locales also for addons
  955. # 'string' : os.path.join(TMPDIR, name, 'locale'),
  956. 'string': os.path.join(TMPDIR, name),
  957. 'etc': os.path.join(TMPDIR, name, 'etc'),
  958. }
  959. make_cmd = [
  960. 'make',
  961. 'MODULE_TOPDIR=%s' % gisbase.replace(' ', r'\ '),
  962. 'RUN_GISRC=%s' % os.environ['GISRC'],
  963. 'BIN=%s' % dirs['bin'],
  964. 'HTMLDIR=%s' % dirs['html'],
  965. 'RESTDIR=%s' % dirs['rest'],
  966. 'MANBASEDIR=%s' % dirs['man'],
  967. 'SCRIPTDIR=%s' % dirs['script'],
  968. 'STRINGDIR=%s' % dirs['string'],
  969. 'ETC=%s' % os.path.join(dirs['etc'])
  970. ]
  971. install_cmd = [
  972. 'make',
  973. 'MODULE_TOPDIR=%s' % gisbase,
  974. 'ARCH_DISTDIR=%s' % os.path.join(TMPDIR, name),
  975. 'INST_DIR=%s' % options['prefix'],
  976. 'install'
  977. ]
  978. if flags['d']:
  979. grass.message("\n%s\n" % _("To compile run:"))
  980. sys.stderr.write(' '.join(make_cmd) + '\n')
  981. grass.message("\n%s\n" % _("To install run:"))
  982. sys.stderr.write(' '.join(install_cmd) + '\n')
  983. return 0
  984. os.chdir(os.path.join(TMPDIR, name))
  985. grass.message(_("Compiling..."))
  986. if not os.path.exists(os.path.join(gisbase, 'include',
  987. 'Make', 'Module.make')):
  988. grass.fatal(_("Please install GRASS development package"))
  989. if 0 != grass.call(make_cmd,
  990. stdout=outdev):
  991. grass.fatal(_('Compilation failed, sorry.'
  992. ' Please check above error messages.'))
  993. if flags['i']:
  994. return 0
  995. grass.message(_("Installing..."))
  996. return grass.call(install_cmd,
  997. stdout=outdev)
  998. def remove_extension(force=False):
  999. """Remove existing extension (module or toolbox if -t is given)"""
  1000. if flags['t']:
  1001. mlist = get_toolbox_modules(options['prefix'], options['extension'])
  1002. else:
  1003. mlist = [options['extension']]
  1004. if force:
  1005. grass.verbose(_("List of removed files:"))
  1006. else:
  1007. grass.info(_("Files to be removed:"))
  1008. remove_modules(mlist, force)
  1009. if force:
  1010. grass.message(_("Updating addons metadata file..."))
  1011. remove_extension_xml(mlist)
  1012. grass.message(_("Extension <%s> successfully uninstalled.") %
  1013. options['extension'])
  1014. else:
  1015. grass.warning(_("Extension <%s> not removed. "
  1016. "Re-run '%s' with '-f' flag to force removal")
  1017. % (options['extension'], 'g.extension'))
  1018. # remove existing extension(s) (reading XML file)
  1019. def remove_modules(mlist, force=False):
  1020. """Remove extensions/modules specified in a list
  1021. Collects the file names from the file with metadata and fallbacks
  1022. to standard layout of files on prefix path on error.
  1023. """
  1024. # try to read XML metadata file first
  1025. xml_file = os.path.join(options['prefix'], 'modules.xml')
  1026. installed = get_installed_modules()
  1027. if os.path.exists(xml_file):
  1028. tree = etree_fromfile(xml_file)
  1029. else:
  1030. tree = None
  1031. for name in mlist:
  1032. if name not in installed:
  1033. # try even if module does not seem to be available,
  1034. # as the user may be trying to get rid of left over cruft
  1035. grass.warning(_("Extension <%s> not found") % name)
  1036. if tree is not None:
  1037. flist = []
  1038. for task in tree.findall('task'):
  1039. if name == task.get('name') and \
  1040. task.find('binary') is not None:
  1041. for file_node in task.find('binary').findall('file'):
  1042. flist.append(file_node.text)
  1043. break
  1044. if flist:
  1045. removed = False
  1046. err = list()
  1047. for fpath in flist:
  1048. try:
  1049. if force:
  1050. grass.verbose(fpath)
  1051. removed = True
  1052. os.remove(fpath)
  1053. else:
  1054. print(fpath)
  1055. except OSError:
  1056. err.append((_("Unable to remove file '%s'") % fpath))
  1057. if force and not removed:
  1058. grass.fatal(_("Extension <%s> not found") % name)
  1059. if err:
  1060. for error_line in err:
  1061. grass.error(error_line)
  1062. else:
  1063. remove_extension_std(name, force)
  1064. else:
  1065. remove_extension_std(name, force)
  1066. def remove_extension_std(name, force=False):
  1067. """Remove extension/module expecting the standard layout"""
  1068. for fpath in [os.path.join(options['prefix'], 'bin', name),
  1069. os.path.join(options['prefix'], 'scripts', name),
  1070. os.path.join(
  1071. options['prefix'], 'docs', 'html', name + '.html'),
  1072. os.path.join(
  1073. options['prefix'], 'docs', 'rest', name + '.txt'),
  1074. os.path.join(options['prefix'], 'docs', 'man', 'man1',
  1075. name + '.1')]:
  1076. if os.path.isfile(fpath):
  1077. if force:
  1078. grass.verbose(fpath)
  1079. os.remove(fpath)
  1080. else:
  1081. print(fpath)
  1082. def remove_from_toolbox_xml(name):
  1083. """Update local meta-file when removing existing toolbox"""
  1084. xml_file = os.path.join(options['prefix'], 'toolboxes.xml')
  1085. if not os.path.exists(xml_file):
  1086. return
  1087. # read XML file
  1088. tree = etree_fromfile(xml_file)
  1089. for node in tree.findall('toolbox'):
  1090. if node.get('code') != name:
  1091. continue
  1092. tree.remove(node)
  1093. write_xml_toolboxes(xml_file, tree)
  1094. def remove_extension_xml(modules):
  1095. """Update local meta-file when removing existing extension"""
  1096. if len(modules) > 1:
  1097. # update also toolboxes metadata
  1098. remove_from_toolbox_xml(options['extension'])
  1099. xml_file = os.path.join(options['prefix'], 'modules.xml')
  1100. if not os.path.exists(xml_file):
  1101. return
  1102. # read XML file
  1103. tree = etree_fromfile(xml_file)
  1104. for name in modules:
  1105. for node in tree.findall('task'):
  1106. if node.get('name') != name:
  1107. continue
  1108. tree.remove(node)
  1109. write_xml_modules(xml_file, tree)
  1110. # check links in CSS
  1111. def check_style_files(fil):
  1112. """Ensures that a specified HTML documentation support file exists
  1113. If the file, e.g. a CSS file does not exist, the file is copied from
  1114. the distribution.
  1115. """
  1116. dist_file = os.path.join(os.getenv('GISBASE'), 'docs', 'html', fil)
  1117. addons_file = os.path.join(options['prefix'], 'docs', 'html', fil)
  1118. if os.path.isfile(addons_file):
  1119. return
  1120. try:
  1121. shutil.copyfile(dist_file, addons_file)
  1122. except OSError as error:
  1123. grass.fatal(_("Unable to create '%s': %s") % (addons_file, error))
  1124. def create_dir(path):
  1125. """Creates the specified directory (with all dirs in between)
  1126. NOOP for existing directory.
  1127. """
  1128. if os.path.isdir(path):
  1129. return
  1130. try:
  1131. os.makedirs(path)
  1132. except OSError as error:
  1133. grass.fatal(_("Unable to create '%s': %s") % (path, error))
  1134. grass.debug("'%s' created" % path)
  1135. def check_dirs():
  1136. """Ensure that the necessary directories in prefix path exist"""
  1137. create_dir(os.path.join(options['prefix'], 'bin'))
  1138. create_dir(os.path.join(options['prefix'], 'docs', 'html'))
  1139. create_dir(os.path.join(options['prefix'], 'docs', 'rest'))
  1140. check_style_files('grass_logo.png')
  1141. check_style_files('grassdocs.css')
  1142. create_dir(os.path.join(options['prefix'], 'etc'))
  1143. create_dir(os.path.join(options['prefix'], 'docs', 'man', 'man1'))
  1144. create_dir(os.path.join(options['prefix'], 'scripts'))
  1145. # fix file URI in manual page
  1146. def update_manual_page(module):
  1147. """Fix manual page for addons which are at different directory then rest"""
  1148. if module.split('.', 1)[0] == 'wx':
  1149. return # skip for GUI modules
  1150. grass.verbose(_("Manual page for <%s> updated") % module)
  1151. # read original html file
  1152. htmlfile = os.path.join(
  1153. options['prefix'], 'docs', 'html', module + '.html')
  1154. try:
  1155. oldfile = open(htmlfile)
  1156. shtml = oldfile.read()
  1157. except IOError as error:
  1158. gscript.fatal(_("Unable to read manual page: %s") % error)
  1159. else:
  1160. oldfile.close()
  1161. pos = []
  1162. # fix logo URL
  1163. pattern = r'''<a href="([^"]+)"><img src="grass_logo.png"'''
  1164. for match in re.finditer(pattern, shtml):
  1165. pos.append(match.start(1))
  1166. # find URIs
  1167. pattern = r'''<a href="([^"]+)">([^>]+)</a>'''
  1168. addons = get_installed_extensions(force=True)
  1169. for match in re.finditer(pattern, shtml):
  1170. if match.group(1)[:4] == 'http':
  1171. continue
  1172. if match.group(1).replace('.html', '') in addons:
  1173. continue
  1174. pos.append(match.start(1))
  1175. if not pos:
  1176. return # no match
  1177. # replace file URIs
  1178. prefix = 'file://' + '/'.join([os.getenv('GISBASE'), 'docs', 'html'])
  1179. ohtml = shtml[:pos[0]]
  1180. for i in range(1, len(pos)):
  1181. ohtml += prefix + '/' + shtml[pos[i - 1]:pos[i]]
  1182. ohtml += prefix + '/' + shtml[pos[-1]:]
  1183. # write updated html file
  1184. try:
  1185. newfile = open(htmlfile, 'w')
  1186. newfile.write(ohtml)
  1187. except IOError as error:
  1188. gscript.fatal(_("Unable for write manual page: %s") % error)
  1189. else:
  1190. newfile.close()
  1191. def resolve_install_prefix(path, to_system):
  1192. """Determine and check the path for installation"""
  1193. if to_system:
  1194. path = os.environ['GISBASE']
  1195. if path == '$GRASS_ADDON_BASE':
  1196. if not os.getenv('GRASS_ADDON_BASE'):
  1197. grass.warning(_("GRASS_ADDON_BASE is not defined, "
  1198. "installing to ~/.grass%s/addons") % version[0])
  1199. path = os.path.join(
  1200. os.environ['HOME'], '.grass%s' % version[0], 'addons')
  1201. else:
  1202. path = os.environ['GRASS_ADDON_BASE']
  1203. if os.path.exists(path) and \
  1204. not os.access(path, os.W_OK):
  1205. grass.fatal(_("You don't have permission to install extension to <{}>."
  1206. " Try to run {} with administrator rights"
  1207. " (su or sudo).")
  1208. .format(path, 'g.extension'))
  1209. # ensure dir sep at the end for cases where path is used as URL and pasted
  1210. # together with file names
  1211. if not path.endswith(os.path.sep):
  1212. path = path + os.path.sep
  1213. return os.path.abspath(path) # make likes absolute paths
  1214. def resolve_xmlurl_prefix(url, source=None):
  1215. """Determine and check the URL where the XML metadata files are stored
  1216. It ensures that there is a single slash at the end of URL, so we can attach
  1217. file name easily:
  1218. >>> resolve_xmlurl_prefix('http://grass.osgeo.org/addons')
  1219. 'http://grass.osgeo.org/addons/'
  1220. >>> resolve_xmlurl_prefix('http://grass.osgeo.org/addons/')
  1221. 'http://grass.osgeo.org/addons/'
  1222. """
  1223. gscript.debug("resolve_xmlurl_prefix(url={}, source={})".format(url, source))
  1224. if source == 'official':
  1225. # use pregenerated modules XML file
  1226. url = 'http://grass.osgeo.org/addons/grass%s/' % version[0]
  1227. # else try to get modules XMl from SVN repository (provided URL)
  1228. # the exact action depends on subsequent code (somewhere)
  1229. if not url.endswith('/'):
  1230. url = url + '/'
  1231. return url
  1232. KNOWN_HOST_SERVICES_INFO = {
  1233. 'OSGeo Trac': {
  1234. 'domain': 'trac.osgeo.org',
  1235. 'ignored_suffixes': ['format=zip'],
  1236. 'possible_starts': ['', 'https://', 'http://'],
  1237. 'url_start': 'https://',
  1238. 'url_end': '?format=zip',
  1239. },
  1240. 'GitHub': {
  1241. 'domain': 'github.com',
  1242. 'ignored_suffixes': ['.zip', '.tar.gz'],
  1243. 'possible_starts': ['', 'https://', 'http://'],
  1244. 'url_start': 'https://',
  1245. 'url_end': '/archive/master.zip',
  1246. },
  1247. 'GitLab': {
  1248. 'domain': 'gitlab.com',
  1249. 'ignored_suffixes': ['.zip', '.tar.gz', '.tar.bz2', '.tar'],
  1250. 'possible_starts': ['', 'https://', 'http://'],
  1251. 'url_start': 'https://',
  1252. 'url_end': '/repository/archive.zip',
  1253. },
  1254. 'Bitbucket': {
  1255. 'domain': 'bitbucket.org',
  1256. 'ignored_suffixes': ['.zip', '.tar.gz', '.gz', '.bz2'],
  1257. 'possible_starts': ['', 'https://', 'http://'],
  1258. 'url_start': 'https://',
  1259. 'url_end': '/get/default.zip',
  1260. },
  1261. }
  1262. # TODO: support ZIP URLs which don't end with zip
  1263. # https://gitlab.com/user/reponame/repository/archive.zip?ref=b%C3%A9po
  1264. def resolve_known_host_service(url):
  1265. """Determine source type and full URL for known hosting service
  1266. If the service is not determined from the provided URL, tuple with
  1267. is two ``None`` values is returned.
  1268. """
  1269. match = None
  1270. actual_start = None
  1271. for key, value in KNOWN_HOST_SERVICES_INFO.iteritems():
  1272. for start in value['possible_starts']:
  1273. if url.startswith(start + value['domain']):
  1274. match = value
  1275. actual_start = start
  1276. gscript.verbose(_("Indentified {} as known hosting service")
  1277. .format(key))
  1278. for suffix in value['ignored_suffixes']:
  1279. if url.endswith(suffix):
  1280. gscript.verbose(
  1281. _("Not using {service} as known hosting service"
  1282. " because the URL ends with '{suffix}'")
  1283. .format(service=key, suffix=suffix))
  1284. return None, None
  1285. if match:
  1286. if not actual_start:
  1287. actual_start = match['url_start']
  1288. else:
  1289. actual_start = ''
  1290. url = '{prefix}{base}{suffix}'.format(prefix=actual_start,
  1291. base=url.rstrip('/'),
  1292. suffix=match['url_end'])
  1293. gscript.verbose(_("Will use the following URL for download: {}")
  1294. .format(url))
  1295. return 'remote_zip', url
  1296. else:
  1297. return None, None
  1298. # TODO: add also option to enforce the source type
  1299. def resolve_source_code(url=None, name=None):
  1300. """Return type and URL or path of the source code
  1301. Local paths are not presented as URLs to be usable in standard functions.
  1302. Path is identified as local path if the directory of file exists which
  1303. has the unfortunate consequence that the not existing files are evaluated
  1304. as remote URLs. When path is not evaluated, Subversion is assumed for
  1305. backwards compatibility. When GitHub repository is specified, ZIP file
  1306. link is returned. The ZIP is for master branch, not the default one because
  1307. GitHub does not provide the deafult branch in the URL (July 2015).
  1308. :returns: tuple with type of source and full URL or path
  1309. Official repository:
  1310. >>> resolve_source_code(name='g.example') # doctest: +SKIP
  1311. ('official', 'https://trac.osgeo.org/.../general/g.example')
  1312. Subversion:
  1313. >>> resolve_source_code('http://svn.osgeo.org/grass/grass-addons/grass7')
  1314. ('svn', 'http://svn.osgeo.org/grass/grass-addons/grass7')
  1315. ZIP files online:
  1316. >>> resolve_source_code('https://trac.osgeo.org/.../r.modis?format=zip')
  1317. ('remote_zip', 'https://trac.osgeo.org/.../r.modis?format=zip')
  1318. Local directories and ZIP files:
  1319. >>> resolve_source_code(os.path.expanduser("~")) # doctest: +ELLIPSIS
  1320. ('dir', '...')
  1321. >>> resolve_source_code('/local/directory/downloaded.zip') # doctest: +SKIP
  1322. ('zip', '/local/directory/downloaded.zip')
  1323. OSGeo Trac:
  1324. >>> resolve_source_code('trac.osgeo.org/.../r.agent.aco')
  1325. ('remote_zip', 'https://trac.osgeo.org/.../r.agent.aco?format=zip')
  1326. >>> resolve_source_code('https://trac.osgeo.org/.../r.agent.aco')
  1327. ('remote_zip', 'https://trac.osgeo.org/.../r.agent.aco?format=zip')
  1328. GitHub:
  1329. >>> resolve_source_code('github.com/user/g.example')
  1330. ('remote_zip', 'https://github.com/user/g.example/archive/master.zip')
  1331. >>> resolve_source_code('github.com/user/g.example/')
  1332. ('remote_zip', 'https://github.com/user/g.example/archive/master.zip')
  1333. >>> resolve_source_code('https://github.com/user/g.example')
  1334. ('remote_zip', 'https://github.com/user/g.example/archive/master.zip')
  1335. >>> resolve_source_code('https://github.com/user/g.example/')
  1336. ('remote_zip', 'https://github.com/user/g.example/archive/master.zip')
  1337. GitLab:
  1338. >>> resolve_source_code('gitlab.com/JoeUser/GrassModule')
  1339. ('remote_zip', 'https://gitlab.com/JoeUser/GrassModule/repository/archive.zip')
  1340. >>> resolve_source_code('https://gitlab.com/JoeUser/GrassModule')
  1341. ('remote_zip', 'https://gitlab.com/JoeUser/GrassModule/repository/archive.zip')
  1342. Bitbucket:
  1343. >>> resolve_source_code('bitbucket.org/joe-user/grass-module')
  1344. ('remote_zip', 'https://bitbucket.org/joe-user/grass-module/get/default.zip')
  1345. >>> resolve_source_code('https://bitbucket.org/joe-user/grass-module')
  1346. ('remote_zip', 'https://bitbucket.org/joe-user/grass-module/get/default.zip')
  1347. """
  1348. if not url and name:
  1349. module_class = get_module_class_name(name)
  1350. trac_url = 'https://trac.osgeo.org/grass/browser/grass-addons/' \
  1351. 'grass{version}/{module_class}/{module_name}?format=zip' \
  1352. .format(version=version[0],
  1353. module_class=module_class, module_name=name)
  1354. return 'official', trac_url
  1355. if os.path.isdir(url):
  1356. return 'dir', os.path.abspath(url)
  1357. elif os.path.exists(url):
  1358. if url.endswith('.zip'):
  1359. return 'zip', os.path.abspath(url)
  1360. for suffix in extract_tar.supported_formats:
  1361. if url.endswith('.' + suffix):
  1362. return suffix, os.path.abspath(url)
  1363. else:
  1364. source, resolved_url = resolve_known_host_service(url)
  1365. if source:
  1366. return source, resolved_url
  1367. # we allow URL to end with =zip or ?zip and not only .zip
  1368. # unfortunately format=zip&version=89612 would require something else
  1369. # special option to force the source type would solve it
  1370. if url.endswith('zip'):
  1371. return 'remote_zip', url
  1372. for suffix in extract_tar.supported_formats:
  1373. if url.endswith(suffix):
  1374. return 'remote_' + suffix, url
  1375. # fallback to the classic behavior
  1376. return 'svn', url
  1377. def main():
  1378. # check dependecies
  1379. if not flags['a'] and sys.platform != "win32":
  1380. check_progs()
  1381. original_url = options['url']
  1382. # manage proxies
  1383. global PROXIES
  1384. if options['proxy']:
  1385. PROXIES = {}
  1386. for ptype, purl in (p.split('=') for p in options['proxy'].split(',')):
  1387. PROXIES[ptype] = purl
  1388. # define path
  1389. options['prefix'] = resolve_install_prefix(path=options['prefix'],
  1390. to_system=flags['s'])
  1391. # list available extensions
  1392. if flags['l'] or flags['c'] or flags['g']:
  1393. # using dummy module, we don't need any module URL now,
  1394. # but will work only as long as the function does not check
  1395. # if the URL is actually valid or something
  1396. source, url = resolve_source_code(name='dummy',
  1397. url=original_url)
  1398. xmlurl = resolve_xmlurl_prefix(original_url, source=source)
  1399. list_available_extensions(xmlurl)
  1400. return 0
  1401. elif flags['a']:
  1402. list_installed_extensions(toolboxes=flags['t'])
  1403. return 0
  1404. if flags['d']:
  1405. if options['operation'] != 'add':
  1406. grass.warning(_("Flag 'd' is relevant only to"
  1407. " 'operation=add'. Ignoring this flag."))
  1408. else:
  1409. global REMOVE_TMPDIR
  1410. REMOVE_TMPDIR = False
  1411. if options['operation'] == 'add':
  1412. check_dirs()
  1413. source, url = resolve_source_code(name=options['extension'],
  1414. url=original_url)
  1415. xmlurl = resolve_xmlurl_prefix(original_url, source=source)
  1416. install_extension(source=source, url=url, xmlurl=xmlurl)
  1417. else: # remove
  1418. remove_extension(force=flags['f'])
  1419. return 0
  1420. if __name__ == "__main__":
  1421. if len(sys.argv) == 2 and sys.argv[1] == '--doctest':
  1422. import doctest
  1423. _ = str # doctest gettext workaround
  1424. sys.exit(doctest.testmod().failed)
  1425. options, flags = grass.parser()
  1426. global TMPDIR
  1427. TMPDIR = tempfile.mkdtemp()
  1428. atexit.register(cleanup)
  1429. version = grass.version()['version'].split('.')
  1430. sys.exit(main())