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