1'use strict' 2 3const path = require('path') 4const log = require('npmlog') 5const semver = require('semver') 6const cp = require('child_process') 7const extend = require('util')._extend // eslint-disable-line 8const win = process.platform === 'win32' 9const logWithPrefix = require('./util').logWithPrefix 10 11function PythonFinder (configPython, callback) { 12 this.callback = callback 13 this.configPython = configPython 14 this.errorLog = [] 15} 16 17PythonFinder.prototype = { 18 log: logWithPrefix(log, 'find Python'), 19 argsExecutable: ['-c', 'import sys; print(sys.executable);'], 20 argsVersion: ['-c', 'import sys; print("%s.%s.%s" % sys.version_info[:3]);'], 21 semverRange: '^2.6.0 || >=3.5.0', 22 23 // These can be overridden for testing: 24 execFile: cp.execFile, 25 env: process.env, 26 win: win, 27 pyLauncher: 'py.exe', 28 winDefaultLocations: [ 29 path.join(process.env.SystemDrive || 'C:', 'Python27', 'python.exe'), 30 path.join(process.env.SystemDrive || 'C:', 'Python37', 'python.exe') 31 ], 32 33 // Logs a message at verbose level, but also saves it to be displayed later 34 // at error level if an error occurs. This should help diagnose the problem. 35 addLog: function addLog (message) { 36 this.log.verbose(message) 37 this.errorLog.push(message) 38 }, 39 40 // Find Python by trying a sequence of possibilities. 41 // Ignore errors, keep trying until Python is found. 42 findPython: function findPython () { 43 const SKIP = 0; const FAIL = 1 44 var toCheck = getChecks.apply(this) 45 46 function getChecks () { 47 if (this.env.NODE_GYP_FORCE_PYTHON) { 48 return [{ 49 before: () => { 50 this.addLog( 51 'checking Python explicitly set from NODE_GYP_FORCE_PYTHON') 52 this.addLog('- process.env.NODE_GYP_FORCE_PYTHON is ' + 53 `"${this.env.NODE_GYP_FORCE_PYTHON}"`) 54 }, 55 check: this.checkCommand, 56 arg: this.env.NODE_GYP_FORCE_PYTHON 57 }] 58 } 59 60 var checks = [ 61 { 62 before: () => { 63 if (!this.configPython) { 64 this.addLog( 65 'Python is not set from command line or npm configuration') 66 return SKIP 67 } 68 this.addLog('checking Python explicitly set from command line or ' + 69 'npm configuration') 70 this.addLog('- "--python=" or "npm config get python" is ' + 71 `"${this.configPython}"`) 72 }, 73 check: this.checkCommand, 74 arg: this.configPython 75 }, 76 { 77 before: () => { 78 if (!this.env.PYTHON) { 79 this.addLog('Python is not set from environment variable ' + 80 'PYTHON') 81 return SKIP 82 } 83 this.addLog('checking Python explicitly set from environment ' + 84 'variable PYTHON') 85 this.addLog(`- process.env.PYTHON is "${this.env.PYTHON}"`) 86 }, 87 check: this.checkCommand, 88 arg: this.env.PYTHON 89 }, 90 { 91 before: () => { this.addLog('checking if "python" can be used') }, 92 check: this.checkCommand, 93 arg: 'python' 94 }, 95 { 96 before: () => { this.addLog('checking if "python2" can be used') }, 97 check: this.checkCommand, 98 arg: 'python2' 99 }, 100 { 101 before: () => { this.addLog('checking if "python3" can be used') }, 102 check: this.checkCommand, 103 arg: 'python3' 104 } 105 ] 106 107 if (this.win) { 108 checks.push({ 109 before: () => { 110 this.addLog( 111 'checking if the py launcher can be used to find Python 2') 112 }, 113 check: this.checkPyLauncher 114 }) 115 for (var i = 0; i < this.winDefaultLocations.length; ++i) { 116 const location = this.winDefaultLocations[i] 117 checks.push({ 118 before: () => { 119 this.addLog('checking if Python is ' + 120 `${location}`) 121 }, 122 check: this.checkExecPath, 123 arg: location 124 }) 125 } 126 } 127 128 return checks 129 } 130 131 function runChecks (err) { 132 this.log.silly('runChecks: err = %j', (err && err.stack) || err) 133 134 const check = toCheck.shift() 135 if (!check) { 136 return this.fail() 137 } 138 139 const before = check.before.apply(this) 140 if (before === SKIP) { 141 return runChecks.apply(this) 142 } 143 if (before === FAIL) { 144 return this.fail() 145 } 146 147 const args = [runChecks.bind(this)] 148 if (check.arg) { 149 args.unshift(check.arg) 150 } 151 check.check.apply(this, args) 152 } 153 154 runChecks.apply(this) 155 }, 156 157 // Check if command is a valid Python to use. 158 // Will exit the Python finder on success. 159 // If on Windows, run in a CMD shell to support BAT/CMD launchers. 160 checkCommand: function checkCommand (command, errorCallback) { 161 var exec = command 162 var args = this.argsExecutable 163 var shell = false 164 if (this.win) { 165 // Arguments have to be manually quoted 166 exec = `"${exec}"` 167 args = args.map(a => `"${a}"`) 168 shell = true 169 } 170 171 this.log.verbose(`- executing "${command}" to get executable path`) 172 this.run(exec, args, shell, function (err, execPath) { 173 // Possible outcomes: 174 // - Error: not in PATH, not executable or execution fails 175 // - Gibberish: the next command to check version will fail 176 // - Absolute path to executable 177 if (err) { 178 this.addLog(`- "${command}" is not in PATH or produced an error`) 179 return errorCallback(err) 180 } 181 this.addLog(`- executable path is "${execPath}"`) 182 this.checkExecPath(execPath, errorCallback) 183 }.bind(this)) 184 }, 185 186 // Check if the py launcher can find a valid Python to use. 187 // Will exit the Python finder on success. 188 // Distributions of Python on Windows by default install with the "py.exe" 189 // Python launcher which is more likely to exist than the Python executable 190 // being in the $PATH. 191 // Because the Python launcher supports all versions of Python, we have to 192 // explicitly request a Python 2 version. This is done by supplying "-2" as 193 // the first command line argument. Since "py.exe -2" would be an invalid 194 // executable for "execFile", we have to use the launcher to figure out 195 // where the actual "python.exe" executable is located. 196 checkPyLauncher: function checkPyLauncher (errorCallback) { 197 this.log.verbose( 198 `- executing "${this.pyLauncher}" to get Python 2 executable path`) 199 this.run(this.pyLauncher, ['-2', ...this.argsExecutable], false, 200 function (err, execPath) { 201 // Possible outcomes: same as checkCommand 202 if (err) { 203 this.addLog( 204 `- "${this.pyLauncher}" is not in PATH or produced an error`) 205 return errorCallback(err) 206 } 207 this.addLog(`- executable path is "${execPath}"`) 208 this.checkExecPath(execPath, errorCallback) 209 }.bind(this)) 210 }, 211 212 // Check if a Python executable is the correct version to use. 213 // Will exit the Python finder on success. 214 checkExecPath: function checkExecPath (execPath, errorCallback) { 215 this.log.verbose(`- executing "${execPath}" to get version`) 216 this.run(execPath, this.argsVersion, false, function (err, version) { 217 // Possible outcomes: 218 // - Error: executable can not be run (likely meaning the command wasn't 219 // a Python executable and the previous command produced gibberish) 220 // - Gibberish: somehow the last command produced an executable path, 221 // this will fail when verifying the version 222 // - Version of the Python executable 223 if (err) { 224 this.addLog(`- "${execPath}" could not be run`) 225 return errorCallback(err) 226 } 227 this.addLog(`- version is "${version}"`) 228 229 const range = new semver.Range(this.semverRange) 230 var valid = false 231 try { 232 valid = range.test(version) 233 } catch (err) { 234 this.log.silly('range.test() threw:\n%s', err.stack) 235 this.addLog(`- "${execPath}" does not have a valid version`) 236 this.addLog('- is it a Python executable?') 237 return errorCallback(err) 238 } 239 240 if (!valid) { 241 this.addLog(`- version is ${version} - should be ${this.semverRange}`) 242 this.addLog('- THIS VERSION OF PYTHON IS NOT SUPPORTED') 243 return errorCallback(new Error( 244 `Found unsupported Python version ${version}`)) 245 } 246 this.succeed(execPath, version) 247 }.bind(this)) 248 }, 249 250 // Run an executable or shell command, trimming the output. 251 run: function run (exec, args, shell, callback) { 252 var env = extend({}, this.env) 253 env.TERM = 'dumb' 254 const opts = { env: env, shell: shell } 255 256 this.log.silly('execFile: exec = %j', exec) 257 this.log.silly('execFile: args = %j', args) 258 this.log.silly('execFile: opts = %j', opts) 259 try { 260 this.execFile(exec, args, opts, execFileCallback.bind(this)) 261 } catch (err) { 262 this.log.silly('execFile: threw:\n%s', err.stack) 263 return callback(err) 264 } 265 266 function execFileCallback (err, stdout, stderr) { 267 this.log.silly('execFile result: err = %j', (err && err.stack) || err) 268 this.log.silly('execFile result: stdout = %j', stdout) 269 this.log.silly('execFile result: stderr = %j', stderr) 270 if (err) { 271 return callback(err) 272 } 273 const execPath = stdout.trim() 274 callback(null, execPath) 275 } 276 }, 277 278 succeed: function succeed (execPath, version) { 279 this.log.info(`using Python version ${version} found at "${execPath}"`) 280 process.nextTick(this.callback.bind(null, null, execPath)) 281 }, 282 283 fail: function fail () { 284 const errorLog = this.errorLog.join('\n') 285 286 const pathExample = this.win ? 'C:\\Path\\To\\python.exe' 287 : '/path/to/pythonexecutable' 288 // For Windows 80 col console, use up to the column before the one marked 289 // with X (total 79 chars including logger prefix, 58 chars usable here): 290 // X 291 const info = [ 292 '**********************************************************', 293 'You need to install the latest version of Python.', 294 'Node-gyp should be able to find and use Python. If not,', 295 'you can try one of the following options:', 296 `- Use the switch --python="${pathExample}"`, 297 ' (accepted by both node-gyp and npm)', 298 '- Set the environment variable PYTHON', 299 '- Set the npm configuration variable python:', 300 ` npm config set python "${pathExample}"`, 301 'For more information consult the documentation at:', 302 'https://github.com/nodejs/node-gyp#installation', 303 '**********************************************************' 304 ].join('\n') 305 306 this.log.error(`\n${errorLog}\n\n${info}\n`) 307 process.nextTick(this.callback.bind(null, new Error( 308 'Could not find any Python installation to use'))) 309 } 310} 311 312function findPython (configPython, callback) { 313 var finder = new PythonFinder(configPython, callback) 314 finder.findPython() 315} 316 317module.exports = findPython 318module.exports.test = { 319 PythonFinder: PythonFinder, 320 findPython: findPython 321} 322