Grass7Utils.py

William Kyngesburye, 2018-03-23 02:52 AM

Download (20.8 KB)

 
1
# -*- coding: utf-8 -*-
2

    
3
"""
4
***************************************************************************
5
    GrassUtils.py
6
    ---------------------
7
    Date                 : February 2015
8
    Copyright            : (C) 2014-2015 by Victor Olaya
9
    Email                : volayaf at gmail dot com
10
***************************************************************************
11
*                                                                         *
12
*   This program is free software; you can redistribute it and/or modify  *
13
*   it under the terms of the GNU General Public License as published by  *
14
*   the Free Software Foundation; either version 2 of the License, or     *
15
*   (at your option) any later version.                                   *
16
*                                                                         *
17
***************************************************************************
18
"""
19

    
20
__author__ = 'Victor Olaya'
21
__date__ = 'February 2015'
22
__copyright__ = '(C) 2014-2015, Victor Olaya'
23

    
24
# This will get replaced with a git SHA1 when you do a git archive
25

    
26
__revision__ = '49858ca4978819f604fa70b9ad09541dbf493ec9'
27

    
28
import stat
29
import shutil
30
import shlex
31
import subprocess
32
import os
33

    
34
from qgis.core import (Qgis,
35
                       QgsApplication,
36
                       QgsProcessingUtils,
37
                       QgsMessageLog)
38
from qgis.PyQt.QtCore import QCoreApplication
39
from processing.core.ProcessingConfig import ProcessingConfig
40
from processing.tools.system import userFolder, isWindows, isMac, mkdir
41
from processing.tests.TestData import points
42
from processing.algs.gdal.GdalUtils import GdalUtils
43

    
44

    
45
class Grass7Utils:
46

    
47
    GRASS_REGION_XMIN = 'GRASS7_REGION_XMIN'
48
    GRASS_REGION_YMIN = 'GRASS7_REGION_YMIN'
49
    GRASS_REGION_XMAX = 'GRASS7_REGION_XMAX'
50
    GRASS_REGION_YMAX = 'GRASS7_REGION_YMAX'
51
    GRASS_REGION_CELLSIZE = 'GRASS7_REGION_CELLSIZE'
52
    GRASS_FOLDER = 'GRASS7_FOLDER'
53
    GRASS_LOG_COMMANDS = 'GRASS7_LOG_COMMANDS'
54
    GRASS_LOG_CONSOLE = 'GRASS7_LOG_CONSOLE'
55
    GRASS_HELP_PATH = 'GRASS_HELP_PATH'
56
    GRASS_USE_VEXTERNAL = 'GRASS_USE_VEXTERNAL'
57

    
58
    # TODO Review all default options formats
59
    GRASS_RASTER_FORMATS_CREATEOPTS = {
60
        'GTiff': 'TFW=YES,COMPRESS=LZW',
61
        'PNG': 'ZLEVEL=9',
62
        'WEBP': 'QUALITY=85'
63
    }
64

    
65
    sessionRunning = False
66
    sessionLayers = {}
67
    projectionSet = False
68

    
69
    isGrassInstalled = False
70

    
71
    version = None
72
    path = None
73
    command = None
74

    
75
    @staticmethod
76
    def grassBatchJobFilename():
77
        """
78
        The Batch file is executed by GRASS binary.
79
        On GNU/Linux and MacOSX it will be executed by a shell.
80
        On MS-Windows, it will be executed by cmd.exe.
81
        """
82
        gisdbase = Grass7Utils.grassDataFolder()
83
        if isWindows():
84
            batchFile = os.path.join(gisdbase, 'grass_batch_job.cmd')
85
        else:
86
            batchFile = os.path.join(gisdbase, 'grass_batch_job.sh')
87
        return batchFile
88

    
89
    @staticmethod
90
    def installedVersion(run=False):
91
        """
92
        Returns the installed version of GRASS by
93
        launching the GRASS command with -v parameter.
94
        """
95
        if Grass7Utils.isGrassInstalled and not run:
96
            return Grass7Utils.version
97

    
98
        if Grass7Utils.grassBin() is None:
99
            return None
100

    
101
        # Launch GRASS command with -v parameter
102
        # For MS-Windows, hide the console
103
        if isWindows():
104
            si = subprocess.STARTUPINFO()
105
            si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
106
            si.wShowWindow = subprocess.SW_HIDE
107
        with subprocess.Popen(
108
                [Grass7Utils.command, '-v'],
109
                shell=True if isMac() else False,
110
                stdout=subprocess.PIPE,
111
                stdin=subprocess.DEVNULL,
112
                stderr=subprocess.STDOUT,
113
                universal_newlines=True,
114
                startupinfo=si if isWindows() else None
115
        ) as proc:
116
            try:
117
                lines = proc.stdout.readlines()
118
                for line in lines:
119
                    if "GRASS GIS " in line:
120
                        line = line.split(" ")[-1].strip()
121
                        if line.startswith("7."):
122
                            Grass7Utils.version = line
123
                            return Grass7Utils.version
124
            except:
125
                pass
126

    
127
        return None
128

    
129
    @staticmethod
130
    def grassBin():
131
        """
132
        Find GRASS binary path on the operating system.
133
        Sets global variable Grass7Utils.command
134
        """
135
        cmdList = ["grass74", "grass72", "grass71", "grass70", "grass",
136
                   "grass74.sh", "grass72.sh", "grass71.sh", "grass70.sh", "grass.sh"]
137

    
138
        def searchFolder(folder):
139
            """
140
            Inline function to search for grass binaries into a folder
141
            with os.walk
142
            """
143
            command = None
144
            if os.path.exists(folder):
145
                for root, dirs, files in os.walk(folder):
146
                    for cmd in cmdList:
147
                        if cmd in files:
148
                            command = os.path.join(root, cmd)
149
                            break
150
                    if command is not None:
151
                            break
152
            return command
153

    
154
        if Grass7Utils.command:
155
            return Grass7Utils.command
156

    
157
        path = Grass7Utils.grassPath()
158
        command = None
159

    
160
        # For MS-Windows there is a difference between GRASS Path and GRASS binary
161
        if isWindows():
162
            # If nothing found, use OSGEO4W or QgsPrefix:
163
            if "OSGEO4W_ROOT" in os.environ:
164
                testFolder = str(os.environ['OSGEO4W_ROOT'])
165
            else:
166
                testFolder = str(QgsApplication.prefixPath())
167
            testFolder = os.path.join(testFolder, 'bin')
168
            command = searchFolder(testFolder)
169
        elif isMac():
170
            # Search in grassPath
171
            command = searchFolder(path)
172

    
173
        # Under GNU/Linux or if everything has failed, use shutil
174
        if not command:
175
            for cmd in cmdList:
176
                testBin = shutil.which(cmd)
177
                if testBin:
178
                    command = os.path.abspath(testBin)
179
                    break
180

    
181
        if command:
182
            Grass7Utils.command = command
183
            if path is '':
184
                Grass7Utils.path = os.path.dirname(command)
185

    
186
        return command
187

    
188
    @staticmethod
189
    def grassPath():
190
        """
191
        Find GRASS path on the operating system.
192
        Sets global variable Grass7Utils.path
193
        """
194
        if Grass7Utils.path is not None:
195
            return Grass7Utils.path
196

    
197
        if not isWindows() and not isMac():
198
            return ''
199

    
200
        folder = ProcessingConfig.getSetting(Grass7Utils.GRASS_FOLDER) or ''
201
        if not os.path.exists(folder):
202
            folder = None
203

    
204
        if folder is None:
205
            # Under MS-Windows, we use OSGEO4W or QGIS Path for folder
206
            if isWindows():
207
                if "OSGEO4W_ROOT" in os.environ:
208
                    testfolder = os.path.join(str(os.environ['OSGEO4W_ROOT']), "apps")
209
                else:
210
                    testfolder = str(QgsApplication.prefixPath())
211
                testfolder = os.path.join(testfolder, 'grass')
212
                if os.path.isdir(testfolder):
213
                    for subfolder in os.listdir(testfolder):
214
                        if subfolder.startswith('grass-7'):
215
                            folder = os.path.join(testfolder, subfolder)
216
                            break
217
            elif isMac():
218
                # For MacOSX, we scan some well-known directories
219
                # Start with QGIS bundle
220
                for version in ['', '7', '70', '71', '72', '74']:
221
                    testfolder = os.path.join(str(QgsApplication.prefixPath()),
222
                                              'grass{}'.format(version))
223
                    if os.path.isdir(testfolder):
224
                        folder = testfolder
225
                        break
226
                    # If nothing found, try standalone GRASS installation
227
                    if folder is None:
228
                        for version in ['0', '1', '2', '4']:
229
                            testfolder = '/Applications/GRASS-7.{}.app/Contents/MacOS'.format(version)
230
                            if os.path.isdir(testfolder):
231
                                folder = testfolder
232
                                break
233

    
234
        if folder is not None:
235
            Grass7Utils.path = folder
236

    
237
        return folder or ''
238

    
239
    @staticmethod
240
    def grassDescriptionPath():
241
        return os.path.join(os.path.dirname(__file__), 'description')
242

    
243
    @staticmethod
244
    def getWindowsCodePage():
245
        """
246
        Determines MS-Windows CMD.exe shell codepage.
247
        Used into GRASS exec script under MS-Windows.
248
        """
249
        from ctypes import cdll
250
        return str(cdll.kernel32.GetACP())
251

    
252
    @staticmethod
253
    def createGrassBatchJobFileFromGrassCommands(commands):
254
        with open(Grass7Utils.grassBatchJobFilename(), 'w') as fout:
255
            if not isWindows():
256
                fout.write('#!/bin/sh\n')
257
            else:
258
                fout.write('chcp {}>NUL\n'.format(Grass7Utils.getWindowsCodePage()))
259
            for command in commands:
260
                Grass7Utils.writeCommand(fout, command)
261
            fout.write('exit')
262

    
263
    @staticmethod
264
    def grassMapsetFolder():
265
        """
266
        Creates and returns the GRASS temporary DB LOCATION directory.
267
        """
268
        folder = os.path.join(Grass7Utils.grassDataFolder(), 'temp_location')
269
        mkdir(folder)
270
        return folder
271

    
272
    @staticmethod
273
    def grassDataFolder():
274
        """
275
        Creates and returns the GRASS temporary DB directory.
276
        """
277
        tempfolder = os.path.normpath(
278
            os.path.join(QgsProcessingUtils.tempFolder(), 'grassdata'))
279
        mkdir(tempfolder)
280
        return tempfolder
281

    
282
    @staticmethod
283
    def createTempMapset():
284
        """
285
        Creates a temporary location and mapset(s) for GRASS data
286
        processing. A minimal set of folders and files is created in the
287
        system's default temporary directory. The settings files are
288
        written with sane defaults, so GRASS can do its work. The mapset
289
        projection will be set later, based on the projection of the first
290
        input image or vector
291
        """
292
        folder = Grass7Utils.grassMapsetFolder()
293
        mkdir(os.path.join(folder, 'PERMANENT'))
294
        mkdir(os.path.join(folder, 'PERMANENT', '.tmp'))
295
        Grass7Utils.writeGrassWindow(os.path.join(folder, 'PERMANENT', 'DEFAULT_WIND'))
296
        with open(os.path.join(folder, 'PERMANENT', 'MYNAME'), 'w') as outfile:
297
            outfile.write(
298
                'QGIS GRASS GIS 7 interface: temporary data processing location.\n')
299

    
300
        Grass7Utils.writeGrassWindow(os.path.join(folder, 'PERMANENT', 'WIND'))
301
        mkdir(os.path.join(folder, 'PERMANENT', 'sqlite'))
302
        with open(os.path.join(folder, 'PERMANENT', 'VAR'), 'w') as outfile:
303
            outfile.write('DB_DRIVER: sqlite\n')
304
            outfile.write('DB_DATABASE: $GISDBASE/$LOCATION_NAME/$MAPSET/sqlite/sqlite.db\n')
305

    
306
    @staticmethod
307
    def writeGrassWindow(filename):
308
        """
309
        Creates the GRASS Window file
310
        """
311
        with open(filename, 'w') as out:
312
            out.write('proj:       0\n')
313
            out.write('zone:       0\n')
314
            out.write('north:      1\n')
315
            out.write('south:      0\n')
316
            out.write('east:       1\n')
317
            out.write('west:       0\n')
318
            out.write('cols:       1\n')
319
            out.write('rows:       1\n')
320
            out.write('e-w resol:  1\n')
321
            out.write('n-s resol:  1\n')
322
            out.write('top:        1\n')
323
            out.write('bottom:     0\n')
324
            out.write('cols3:      1\n')
325
            out.write('rows3:      1\n')
326
            out.write('depths:     1\n')
327
            out.write('e-w resol3: 1\n')
328
            out.write('n-s resol3: 1\n')
329
            out.write('t-b resol:  1\n')
330

    
331
    @staticmethod
332
    def prepareGrassExecution(commands):
333
        """
334
        Prepare GRASS batch job in a script and
335
        returns it as a command ready for subprocess.
336
        """
337
        env = os.environ.copy()
338
        env['GRASS_MESSAGE_FORMAT'] = 'plain'
339
        if 'GISBASE' in env:
340
            del env['GISBASE']
341
        Grass7Utils.createGrassBatchJobFileFromGrassCommands(commands)
342
        os.chmod(Grass7Utils.grassBatchJobFilename(), stat.S_IEXEC | stat.S_IREAD | stat.S_IWRITE)
343
        command = [Grass7Utils.command,
344
                   os.path.join(Grass7Utils.grassMapsetFolder(), 'PERMANENT'),
345
                   '--exec', Grass7Utils.grassBatchJobFilename()]
346

    
347
        return command, env
348

    
349
    @staticmethod
350
    def executeGrass(commands, feedback, outputCommands=None):
351
        loglines = []
352
        loglines.append(Grass7Utils.tr('GRASS GIS 7 execution console output'))
353
        grassOutDone = False
354
        command, grassenv = Grass7Utils.prepareGrassExecution(commands)
355
        #QgsMessageLog.logMessage('exec: {}'.format(command), 'DEBUG', Qgis.Info)
356

    
357
        # For MS-Windows, we need to hide the console window.
358
        if isWindows():
359
            si = subprocess.STARTUPINFO()
360
            si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
361
            si.wShowWindow = subprocess.SW_HIDE
362

    
363
        with subprocess.Popen(
364
                command,
365
                shell=True if isMac() else False,
366
                stdout=subprocess.PIPE,
367
                stdin=subprocess.DEVNULL,
368
                stderr=subprocess.STDOUT,
369
                universal_newlines=True,
370
                env=grassenv,
371
                startupinfo=si if isWindows() else None
372
        ) as proc:
373
            for line in iter(proc.stdout.readline, ''):
374
                if 'GRASS_INFO_PERCENT' in line:
375
                    try:
376
                        feedback.setProgress(int(line[len('GRASS_INFO_PERCENT') + 2:]))
377
                    except:
378
                        pass
379
                else:
380
                    if 'r.out' in line or 'v.out' in line:
381
                        grassOutDone = True
382
                    loglines.append(line)
383
                    feedback.pushConsoleInfo(line)
384

    
385
        # Some GRASS scripts, like r.mapcalculator or r.fillnulls, call
386
        # other GRASS scripts during execution. This may override any
387
        # commands that are still to be executed by the subprocess, which
388
        # are usually the output ones. If that is the case runs the output
389
        # commands again.
390
        if not grassOutDone and outputCommands:
391
            command, grassenv = Grass7Utils.prepareGrassExecution(outputCommands)
392
            with subprocess.Popen(
393
                    command,
394
                    shell=True if isMac() else False,
395
                    stdout=subprocess.PIPE,
396
                    stdin=subprocess.DEVNULL,
397
                    stderr=subprocess.STDOUT,
398
                    universal_newlines=True,
399
                    env=grassenv,
400
                    startupinfo=si if isWindows() else None
401
            ) as proc:
402
                for line in iter(proc.stdout.readline, ''):
403
                    if 'GRASS_INFO_PERCENT' in line:
404
                        try:
405
                            feedback.setProgress(int(
406
                                line[len('GRASS_INFO_PERCENT') + 2:]))
407
                        except:
408
                            pass
409
                    else:
410
                        loglines.append(line)
411
                        feedback.pushConsoleInfo(line)
412

    
413
        if ProcessingConfig.getSetting(Grass7Utils.GRASS_LOG_CONSOLE):
414
            QgsMessageLog.logMessage('\n'.join(loglines), 'Processing', Qgis.Info)
415

    
416
    # GRASS session is used to hold the layers already exported or
417
    # produced in GRASS between multiple calls to GRASS algorithms.
418
    # This way they don't have to be loaded multiple times and
419
    # following algorithms can use the results of the previous ones.
420
    # Starting a session just involves creating the temp mapset
421
    # structure
422
    @staticmethod
423
    def startGrassSession():
424
        if not Grass7Utils.sessionRunning:
425
            Grass7Utils.createTempMapset()
426
            Grass7Utils.sessionRunning = True
427

    
428
    # End session by removing the temporary GRASS mapset and all
429
    # the layers.
430
    @staticmethod
431
    def endGrassSession():
432
        #shutil.rmtree(Grass7Utils.grassMapsetFolder(), True)
433
        Grass7Utils.sessionRunning = False
434
        Grass7Utils.sessionLayers = {}
435
        Grass7Utils.projectionSet = False
436

    
437
    @staticmethod
438
    def getSessionLayers():
439
        return Grass7Utils.sessionLayers
440

    
441
    @staticmethod
442
    def addSessionLayers(exportedLayers):
443
        Grass7Utils.sessionLayers = dict(
444
            list(Grass7Utils.sessionLayers.items()) +
445
            list(exportedLayers.items()))
446

    
447
    @staticmethod
448
    def checkGrassIsInstalled(ignorePreviousState=False):
449
        if not ignorePreviousState:
450
            if Grass7Utils.isGrassInstalled:
451
                return
452

    
453
        # We check the version of Grass7
454
        if Grass7Utils.installedVersion() is not None:
455
            # For Ms-Windows, we check GRASS binaries
456
            if isWindows():
457
                cmdpath = os.path.join(Grass7Utils.path, 'bin', 'r.out.gdal.exe')
458
                if not os.path.exists(cmdpath):
459
                    return Grass7Utils.tr(
460
                        'The specified GRASS 7 folder "{}" does not contain '
461
                        'a valid set of GRASS 7 modules.\nPlease, go to the '
462
                        'Processing settings dialog, and check that the '
463
                        'GRASS 7\nfolder is correctly configured'.format(os.path.join(Grass7Utils.path, 'bin')))
464
            Grass7Utils.isGrassInstalled = True
465
            return
466
        # Return error messages
467
        else:
468
            # MS-Windows or MacOSX
469
            if isWindows() or isMac():
470
                if Grass7Utils.path is None:
471
                    return Grass7Utils.tr(
472
                        'GRASS GIS 7 folder is not configured. Please configure '
473
                        'it before running GRASS GIS 7 algorithms.')
474
                if Grass7Utils.command is None:
475
                    return Grass7Utils.tr(
476
                        'GRASS GIS 7 binary {0} can\'t be found on this system from a shell. '
477
                        'Please install it or configure your PATH {1} environment variable.'.format(
478
                            '(grass.bat)' if isWindows() else '(grass.sh)',
479
                            'or OSGEO4W_ROOT' if isWindows() else ''))
480
            # GNU/Linux
481
            else:
482
                return Grass7Utils.tr(
483
                    'GRASS 7 can\'t be found on this system from a shell. '
484
                    'Please install it or configure your PATH environment variable.')
485

    
486
    @staticmethod
487
    def tr(string, context=''):
488
        if context == '':
489
            context = 'Grass7Utils'
490
        return QCoreApplication.translate(context, string)
491

    
492
    @staticmethod
493
    def writeCommand(output, command):
494
        try:
495
            # Python 2
496
            output.write(command.encode('utf8') + '\n')
497
        except TypeError:
498
            # Python 3
499
            output.write(command + '\n')
500

    
501
    @staticmethod
502
    def grassHelpPath():
503
        helpPath = ProcessingConfig.getSetting(Grass7Utils.GRASS_HELP_PATH)
504

    
505
        if helpPath is None:
506
            if isWindows() or isMac():
507
                if Grass7Utils.path is not None:
508
                    localPath = os.path.join(Grass7Utils.path, 'docs/html')
509
                    if os.path.exists(localPath):
510
                        helpPath = os.path.abspath(localPath)
511
            else:
512
                searchPaths = ['/usr/share/doc/grass-doc/html',
513
                               '/opt/grass/docs/html',
514
                               '/usr/share/doc/grass/docs/html']
515
                for path in searchPaths:
516
                    if os.path.exists(path):
517
                        helpPath = os.path.abspath(path)
518
                        break
519

    
520
        if helpPath is not None:
521
            return helpPath
522
        elif Grass7Utils.version:
523
            version = Grass7Utils.version.replace('.', '')[:2]
524
            return 'https://grass.osgeo.org/grass{}/manuals/'.format(version)
525
        else:
526
            # GRASS not available!
527
            return 'https://grass.osgeo.org/grass72/manuals/'
528

    
529
    @staticmethod
530
    def getSupportedOutputRasterExtensions():
531
        # We use the same extensions than GDAL because:
532
        # - GRASS is also using GDAL for raster imports.
533
        # - Chances that GRASS is compiled with another version of
534
        # GDAL than QGIS are very limited!
535
        return GdalUtils.getSupportedOutputRasterExtensions()
536

    
537
    @staticmethod
538
    def getRasterFormatFromFilename(filename):
539
        """
540
        Returns Raster format name from a raster filename.
541
        :param filename: The name with extension of the raster.
542
        :return: The Gdal short format name for extension.
543
        """
544
        ext = os.path.splitext(filename)[1].lower()
545
        ext = ext.lstrip('.')
546
        if ext:
547
            supported = GdalUtils.getSupportedRasters()
548
            for name in list(supported.keys()):
549
                exts = supported[name]
550
                if ext in exts:
551
                    return name
552
        return 'GTiff'