codekingpro/portable-devtools
115k
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; sys.stdout.buffer.write(sys.executable.encode(\'utf-8\'));']
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('--python was not set on the command line')
90 return SKIP
91 }
92 this.addLog(`--python=${this.configPython} was set on the command line`)
93 },
94 check: () => this.checkCommand(this.configPython)
95 },
96 {
97 before: () => {
98 if (!this.env.PYTHON) {
99 this.addLog('Python is not set from environment variable ' +
100 'PYTHON')
101 return SKIP
102 }
103 this.addLog('checking Python explicitly set from environment ' +
104 'variable PYTHON')
105 this.addLog(`- process.env.PYTHON is "${this.env.PYTHON}"`)
106 },
107 check: () => this.checkCommand(this.env.PYTHON)
108 }
109 ]
110
111 if (this.win) {
112 checks.push({
113 before: () => {
114 this.addLog(
115 'checking if the py launcher can be used to find Python 3')
116 },
117 check: () => this.checkPyLauncher()
118 })
119 }
120
121 checks.push(...[
122 {
123 before: () => { this.addLog('checking if "python3" can be used') },
124 check: () => this.checkCommand('python3')
125 },
126 {
127 before: () => { this.addLog('checking if "python" can be used') },
128 check: () => this.checkCommand('python')
129 }
130 ])
131
132 if (this.win) {
133 for (let i = 0; i < this.winDefaultLocations.length; ++i) {
134 const location = this.winDefaultLocations[i]
135 checks.push({
136 before: () => this.addLog(`checking if Python is ${location}`),
137 check: () => this.checkExecPath(location)
138 })
139 }
140 }
141
142 return checks
143 })()
144
145 for (const check of toCheck) {
146 const before = check.before()
147 if (before === SKIP) {
148 continue
149 }
150 if (before === FAIL) {
151 return this.fail()
152 }
153 try {
154 return await check.check()
155 } catch (err) {
156 this.log.silly('runChecks: err = %j', (err && err.stack) || err)
157 }
158 }
159
160 return this.fail()
161 }
162
163 // Check if command is a valid Python to use.
164 // Will exit the Python finder on success.
165 // If on Windows, run in a CMD shell to support BAT/CMD launchers.
166 async checkCommand (command) {
167 let exec = command
168 let args = this.argsExecutable
169 let shell = false
170 if (this.win) {
171 // Arguments have to be manually quoted
172 exec = `"${exec}"`
173 args = args.map(a => `"${a}"`)
174 shell = true
175 }
176
177 this.log.verbose(`- executing "${command}" to get executable path`)
178 // Possible outcomes:
179 // - Error: not in PATH, not executable or execution fails
180 // - Gibberish: the next command to check version will fail
181 // - Absolute path to executable
182 try {
183 const execPath = await this.run(exec, args, shell)
184 this.addLog(`- executable path is "${execPath}"`)
185 return this.checkExecPath(execPath)
186 } catch (err) {
187 this.addLog(`- "${command}" is not in PATH or produced an error`)
188 throw err
189 }
190 }
191
192 // Check if the py launcher can find a valid Python to use.
193 // Will exit the Python finder on success.
194 // Distributions of Python on Windows by default install with the "py.exe"
195 // Python launcher which is more likely to exist than the Python executable
196 // being in the $PATH.
197 // Because the Python launcher supports Python 2 and Python 3, we should
198 // explicitly request a Python 3 version. This is done by supplying "-3" as
199 // the first command line argument. Since "py.exe -3" would be an invalid
200 // executable for "execFile", we have to use the launcher to figure out
201 // where the actual "python.exe" executable is located.
202 async checkPyLauncher () {
203 this.log.verbose(`- executing "${this.pyLauncher}" to get Python 3 executable path`)
204 // Possible outcomes: same as checkCommand
205 try {
206 const execPath = await this.run(this.pyLauncher, ['-3', ...this.argsExecutable], false)
207 this.addLog(`- executable path is "${execPath}"`)
208 return this.checkExecPath(execPath)
209 } catch (err) {
210 this.addLog(`- "${this.pyLauncher}" is not in PATH or produced an error`)
211 throw err
212 }
213 }
214
215 // Check if a Python executable is the correct version to use.
216 // Will exit the Python finder on success.
217 async checkExecPath (execPath) {
218 this.log.verbose(`- executing "${execPath}" to get version`)
219 // Possible outcomes:
220 // - Error: executable can not be run (likely meaning the command wasn't
221 // a Python executable and the previous command produced gibberish)
222 // - Gibberish: somehow the last command produced an executable path,
223 // this will fail when verifying the version
224 // - Version of the Python executable
225 try {
226 const version = await this.run(execPath, this.argsVersion, false)
227 this.addLog(`- version is "${version}"`)
228
229 const range = new semver.Range(this.semverRange)
230 let 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 throw err
238 }
239 if (!valid) {
240 this.addLog(`- version is ${version} - should be ${this.semverRange}`)
241 this.addLog('- THIS VERSION OF PYTHON IS NOT SUPPORTED')
242 throw new Error(`Found unsupported Python version ${version}`)
243 }
244 return this.succeed(execPath, version)
245 } catch (err) {
246 this.addLog(`- "${execPath}" could not be run`)
247 throw err
248 }
249 }
250
251 // Run an executable or shell command, trimming the output.
252 async run (exec, args, shell) {
253 const env = Object.assign({}, this.env)
254 env.TERM = 'dumb'
255 const opts = { env, shell }
256
257 this.log.silly('execFile: exec = %j', exec)
258 this.log.silly('execFile: args = %j', args)
259 this.log.silly('execFile: opts = %j', opts)
260 try {
261 const [err, stdout, stderr] = await this.execFile(exec, args, opts)
262 this.log.silly('execFile result: err = %j', (err && err.stack) || err)
263 this.log.silly('execFile result: stdout = %j', stdout)
264 this.log.silly('execFile result: stderr = %j', stderr)
265 return stdout.trim()
266 } catch (err) {
267 this.log.silly('execFile: threw:\n%s', err.stack)
268 throw err
269 }
270 }
271
272 succeed (execPath, version) {
273 this.log.info(`using Python version ${version} found at "${execPath}"`)
274 return execPath
275 }
276
277 fail () {
278 const errorLog = this.errorLog.join('\n')
279
280 const pathExample = this.win
281 ? 'C:\\Path\\To\\python.exe'
282 : '/path/to/pythonexecutable'
283 // For Windows 80 col console, use up to the column before the one marked
284 // with X (total 79 chars including logger prefix, 58 chars usable here):
285 // X
286 const info = [
287 '**********************************************************',
288 'You need to install the latest version of Python.',
289 'Node-gyp should be able to find and use Python. If not,',
290 'you can try one of the following options:',
291 `- Use the switch --python="${pathExample}"`,
292 ' (accepted by both node-gyp and npm)',
293 '- Set the environment variable PYTHON',
294 'For more information consult the documentation at:',
295 'https://github.com/nodejs/node-gyp#installation',
296 '**********************************************************'
297 ].join('\n')
298
299 this.log.error(`\n${errorLog}\n\n${info}\n`)
300 throw new Error('Could not find any Python installation to use')
301 }
302}
303
304module.exports = PythonFinder
305 