diff --git a/gyp/pylib/gyp/easy_xml.py b/gyp/pylib/gyp/easy_xml.py index e0628ef4d8..e8129b139c 100644 --- a/gyp/pylib/gyp/easy_xml.py +++ b/gyp/pylib/gyp/easy_xml.py @@ -2,6 +2,7 @@ # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. +import sys import re import os import locale @@ -121,7 +122,10 @@ def WriteXmlIfChanged(content, path, encoding="utf-8", pretty=False, win32=False default_encoding = locale.getdefaultlocale()[1] if default_encoding and default_encoding.upper() != encoding.upper(): - xml_string = xml_string.encode(encoding) + if sys.platform == "win32" and sys.version_info < (3, 7): + xml_string = xml_string.decode("cp1251").encode(encoding) + else: + xml_string = xml_string.encode(encoding) # Get the old content try: diff --git a/gyp/pylib/gyp/input.py b/gyp/pylib/gyp/input.py index 9039776240..ebeb0ac1aa 100644 --- a/gyp/pylib/gyp/input.py +++ b/gyp/pylib/gyp/input.py @@ -236,7 +236,11 @@ def LoadOneBuildFile(build_file_path, data, aux_data, includes, is_target, check # But since node-gyp produces ebcdic files, do not use that mode. build_file_contents = open(build_file_path, "r").read() else: - build_file_contents = open(build_file_path, "rU").read() + if sys.version_info > (3, 7) and sys.platform == "win32": + build_file_contents = open(build_file_path, 'r', newline=None , encoding="utf8").read() + else: + # "U" flag is used becouse of backward compatibility + build_file_contents = open(build_file_path, 'rU').read() else: raise GypError("%s not found (cwd: %s)" % (build_file_path, os.getcwd())) diff --git a/lib/find-python-script.py b/lib/find-python-script.py new file mode 100644 index 0000000000..170ae9950d --- /dev/null +++ b/lib/find-python-script.py @@ -0,0 +1,11 @@ +import sys, codecs; + +if (sys.stdout.encoding != "utf-8" and sys.platform == "win32"): + if sys.version_info > (3, 7): + sys.stdout.reconfigure(encoding='utf-8') + print(sys.executable) + else: + sys.stdout = codecs.getwriter("utf8")(sys.stdout) + print(sys.executable.decode("cp1251")) +else: + print(sys.executable) \ No newline at end of file diff --git a/lib/find-python.js b/lib/find-python.js index af269de2fc..20254bb8cc 100644 --- a/lib/find-python.js +++ b/lib/find-python.js @@ -1,316 +1,604 @@ -'use strict' - -const path = require('path') -const log = require('npmlog') -const semver = require('semver') -const cp = require('child_process') -const extend = require('util')._extend // eslint-disable-line -const win = process.platform === 'win32' -const logWithPrefix = require('./util').logWithPrefix - -function PythonFinder (configPython, callback) { - this.callback = callback - this.configPython = configPython - this.errorLog = [] -} - -PythonFinder.prototype = { - log: logWithPrefix(log, 'find Python'), - argsExecutable: ['-c', 'import sys; print(sys.executable);'], - argsVersion: ['-c', 'import sys; print("%s.%s.%s" % sys.version_info[:3]);'], - semverRange: '2.7.x || >=3.5.0', - - // These can be overridden for testing: - execFile: cp.execFile, - env: process.env, - win: win, - pyLauncher: 'py.exe', - winDefaultLocations: [ - path.join(process.env.SystemDrive || 'C:', 'Python37', 'python.exe'), - path.join(process.env.SystemDrive || 'C:', 'Python27', 'python.exe') - ], - - // Logs a message at verbose level, but also saves it to be displayed later - // at error level if an error occurs. This should help diagnose the problem. - addLog: function addLog (message) { - this.log.verbose(message) - this.errorLog.push(message) - }, - - // Find Python by trying a sequence of possibilities. - // Ignore errors, keep trying until Python is found. - findPython: function findPython () { - const SKIP = 0; const FAIL = 1 - var toCheck = getChecks.apply(this) - - function getChecks () { - if (this.env.NODE_GYP_FORCE_PYTHON) { - return [{ - before: () => { - this.addLog( - 'checking Python explicitly set from NODE_GYP_FORCE_PYTHON') - this.addLog('- process.env.NODE_GYP_FORCE_PYTHON is ' + - `"${this.env.NODE_GYP_FORCE_PYTHON}"`) - }, - check: this.checkCommand, - arg: this.env.NODE_GYP_FORCE_PYTHON - }] - } - - var checks = [ - { - before: () => { - if (!this.configPython) { - this.addLog( - 'Python is not set from command line or npm configuration') - return SKIP - } - this.addLog('checking Python explicitly set from command line or ' + - 'npm configuration') - this.addLog('- "--python=" or "npm config get python" is ' + - `"${this.configPython}"`) - }, - check: this.checkCommand, - arg: this.configPython - }, - { - before: () => { - if (!this.env.PYTHON) { - this.addLog('Python is not set from environment variable ' + - 'PYTHON') - return SKIP - } - this.addLog('checking Python explicitly set from environment ' + - 'variable PYTHON') - this.addLog(`- process.env.PYTHON is "${this.env.PYTHON}"`) - }, - check: this.checkCommand, - arg: this.env.PYTHON - }, - { - before: () => { this.addLog('checking if "python3" can be used') }, - check: this.checkCommand, - arg: 'python3' - }, - { - before: () => { this.addLog('checking if "python" can be used') }, - check: this.checkCommand, - arg: 'python' - }, - { - before: () => { this.addLog('checking if "python2" can be used') }, - check: this.checkCommand, - arg: 'python2' - } - ] - - if (this.win) { - for (var i = 0; i < this.winDefaultLocations.length; ++i) { - const location = this.winDefaultLocations[i] - checks.push({ - before: () => { - this.addLog('checking if Python is ' + - `${location}`) - }, - check: this.checkExecPath, - arg: location - }) - } - checks.push({ - before: () => { - this.addLog( - 'checking if the py launcher can be used to find Python') - }, - check: this.checkPyLauncher - }) - } - - return checks - } - - function runChecks (err) { - this.log.silly('runChecks: err = %j', (err && err.stack) || err) - - const check = toCheck.shift() - if (!check) { - return this.fail() - } - - const before = check.before.apply(this) - if (before === SKIP) { - return runChecks.apply(this) - } - if (before === FAIL) { - return this.fail() - } - - const args = [runChecks.bind(this)] - if (check.arg) { - args.unshift(check.arg) - } - check.check.apply(this, args) - } - - runChecks.apply(this) - }, - - // Check if command is a valid Python to use. - // Will exit the Python finder on success. - // If on Windows, run in a CMD shell to support BAT/CMD launchers. - checkCommand: function checkCommand (command, errorCallback) { - var exec = command - var args = this.argsExecutable - var shell = false - if (this.win) { - // Arguments have to be manually quoted - exec = `"${exec}"` - args = args.map(a => `"${a}"`) - shell = true - } - - this.log.verbose(`- executing "${command}" to get executable path`) - this.run(exec, args, shell, function (err, execPath) { - // Possible outcomes: - // - Error: not in PATH, not executable or execution fails - // - Gibberish: the next command to check version will fail - // - Absolute path to executable - if (err) { - this.addLog(`- "${command}" is not in PATH or produced an error`) - return errorCallback(err) - } - this.addLog(`- executable path is "${execPath}"`) - this.checkExecPath(execPath, errorCallback) - }.bind(this)) - }, - - // Check if the py launcher can find a valid Python to use. - // Will exit the Python finder on success. - // Distributions of Python on Windows by default install with the "py.exe" - // Python launcher which is more likely to exist than the Python executable - // being in the $PATH. - checkPyLauncher: function checkPyLauncher (errorCallback) { - this.log.verbose( - `- executing "${this.pyLauncher}" to get Python executable path`) - this.run(this.pyLauncher, this.argsExecutable, false, - function (err, execPath) { - // Possible outcomes: same as checkCommand - if (err) { - this.addLog( - `- "${this.pyLauncher}" is not in PATH or produced an error`) - return errorCallback(err) - } - this.addLog(`- executable path is "${execPath}"`) - this.checkExecPath(execPath, errorCallback) - }.bind(this)) - }, - - // Check if a Python executable is the correct version to use. - // Will exit the Python finder on success. - checkExecPath: function checkExecPath (execPath, errorCallback) { - this.log.verbose(`- executing "${execPath}" to get version`) - this.run(execPath, this.argsVersion, false, function (err, version) { - // Possible outcomes: - // - Error: executable can not be run (likely meaning the command wasn't - // a Python executable and the previous command produced gibberish) - // - Gibberish: somehow the last command produced an executable path, - // this will fail when verifying the version - // - Version of the Python executable - if (err) { - this.addLog(`- "${execPath}" could not be run`) - return errorCallback(err) - } - this.addLog(`- version is "${version}"`) - - const range = new semver.Range(this.semverRange) - var valid = false - try { - valid = range.test(version) - } catch (err) { - this.log.silly('range.test() threw:\n%s', err.stack) - this.addLog(`- "${execPath}" does not have a valid version`) - this.addLog('- is it a Python executable?') - return errorCallback(err) - } - - if (!valid) { - this.addLog(`- version is ${version} - should be ${this.semverRange}`) - this.addLog('- THIS VERSION OF PYTHON IS NOT SUPPORTED') - return errorCallback(new Error( - `Found unsupported Python version ${version}`)) - } - this.succeed(execPath, version) - }.bind(this)) - }, - - // Run an executable or shell command, trimming the output. - run: function run (exec, args, shell, callback) { - var env = extend({}, this.env) - env.TERM = 'dumb' - const opts = { env: env, shell: shell } - - this.log.silly('execFile: exec = %j', exec) - this.log.silly('execFile: args = %j', args) - this.log.silly('execFile: opts = %j', opts) - try { - this.execFile(exec, args, opts, execFileCallback.bind(this)) - } catch (err) { - this.log.silly('execFile: threw:\n%s', err.stack) - return callback(err) - } - - function execFileCallback (err, stdout, stderr) { - this.log.silly('execFile result: err = %j', (err && err.stack) || err) - this.log.silly('execFile result: stdout = %j', stdout) - this.log.silly('execFile result: stderr = %j', stderr) - if (err) { - return callback(err) - } - const execPath = stdout.trim() - callback(null, execPath) - } - }, - - succeed: function succeed (execPath, version) { - this.log.info(`using Python version ${version} found at "${execPath}"`) - process.nextTick(this.callback.bind(null, null, execPath)) - }, - - fail: function fail () { - const errorLog = this.errorLog.join('\n') - - const pathExample = this.win ? 'C:\\Path\\To\\python.exe' - : '/path/to/pythonexecutable' - // For Windows 80 col console, use up to the column before the one marked - // with X (total 79 chars including logger prefix, 58 chars usable here): - // X - const info = [ - '**********************************************************', - 'You need to install the latest version of Python.', - 'Node-gyp should be able to find and use Python. If not,', - 'you can try one of the following options:', - `- Use the switch --python="${pathExample}"`, - ' (accepted by both node-gyp and npm)', - '- Set the environment variable PYTHON', - '- Set the npm configuration variable python:', - ` npm config set python "${pathExample}"`, - 'For more information consult the documentation at:', - 'https://github.com/nodejs/node-gyp#installation', - '**********************************************************' - ].join('\n') - - this.log.error(`\n${errorLog}\n\n${info}\n`) - process.nextTick(this.callback.bind(null, new Error( - 'Could not find any Python installation to use'))) - } -} - -function findPython (configPython, callback) { - var finder = new PythonFinder(configPython, callback) - finder.findPython() -} - -module.exports = findPython -module.exports.test = { - PythonFinder: PythonFinder, - findPython: findPython -} +'use strict' +// @ts-check + +const path = require('path') +const log = require('npmlog') +const semver = require('semver') +const cp = require('child_process') +const extend = require('util')._extend // eslint-disable-line +const win = process.platform === 'win32' +const logWithPrefix = require('./util').logWithPrefix + +//! after editing file dont forget run "npm test" and +//! change tests for this file if needed + +// ?may be some addition info in silly and verbose levels +// i hope i made not bad error hanlding but may be some improvments would be nice +// TODO: better error handler on linux/macOS + +const RED = '\x1b[31m' +const RESET = '\x1b[0m' +const GREEN = '\x1b[32m' + +function colorizeOutput (color, string) { + return color + string + RESET +} + +//! on windows debug running with locale cmd (e. g. chcp 866) encoding +// to avoid that uncoment next lines +// locale encdoings couse issues. See run func for more info +// this lines only for testing +// win ? cp.execSync("chcp 65001") : null +// log.level = "silly"; + +/** + * @class + */ +class PythonFinder { + /** + * + * @param {string} configPython force setted from terminal or npm config python path + * @param {(err:Error, found:string) => void} callback succsed/error callback from where result + * is available + */ + constructor (configPython, callback) { + this.callback = callback + this.configPython = configPython + this.errorLog = [] + + this.catchErrors = this.catchErrors.bind(this) + this.checkExecPath = this.checkExecPath.bind(this) + this.succeed = this.succeed.bind(this) + + this.SKIP = 0 + this.FAIL = 1 + + this.log = logWithPrefix(log, 'find Python') + + this.argsExecutable = [path.resolve(__dirname, 'find-python-script.py')] + this.argsVersion = [ + '-c', + 'import sys; print("%s.%s.%s" % sys.version_info[:3]);' + // for testing + // 'print("2.1.1")' + ] + this.semverRange = '2.7.x || >=3.5.0' + + // These can be overridden for testing: + this.execFile = cp.execFile + this.env = process.env + this.win = win + this.pyLauncher = 'py.exe' + this.winDefaultLocations = [ + path.join(process.env.SystemDrive || 'C:', 'Python37', 'python.exe'), + path.join(process.env.SystemDrive || 'C:', 'Python27', 'python.exe') + ] + } + + /** + * Logs a message at verbose level, but also saves it to be displayed later + * at error level if an error occurs. This should help diagnose the problem. + * + * ?message is array or one string + * + * @private + */ + addLog (message) { + this.log.verbose(message) + this.errorLog.push(message) + } + + /** + * Find Python by trying a sequence of possibilities. + * Ignore errors, keep trying until Python is found. + * + * @public + */ + findPython () { + this.toCheck = this.getChecks() + + this.runChecks(this.toCheck) + } + + /** + * Getting list of checks which should be cheked + * + * @private + * @returns {check[]} + */ + getChecks () { + if (this.env.NODE_GYP_FORCE_PYTHON) { + /** + * @type {check[]} + */ + return [ + { + before: () => { + this.addLog( + 'checking Python explicitly set from NODE_GYP_FORCE_PYTHON' + ) + this.addLog( + '- process.env.NODE_GYP_FORCE_PYTHON is ' + + `"${this.env.NODE_GYP_FORCE_PYTHON}"` + ) + }, + checkFunc: this.checkCommand, + arg: this.env.NODE_GYP_FORCE_PYTHON + } + ] + } + + /** + * @type {check[]} + */ + const checks = [ + { + before: (name) => { + if (!this.configPython) { + this.addLog( + `${colorizeOutput( + GREEN, + 'Python is not set from command line or npm configuration' + )}` + ) + this.addLog('') + return this.SKIP + } + this.addLog( + 'checking Python explicitly set from command line or ' + + 'npm configuration' + ) + this.addLog( + '- "--python=" or "npm config get python" is ' + + `"${colorizeOutput(GREEN, this.configPython)}"` + ) + }, + checkFunc: this.checkCommand, + arg: this.configPython + }, + { + before: (name) => { + if (!this.env.PYTHON) { + this.addLog( + `Python is not set from environment variable ${colorizeOutput( + GREEN, + 'PYTHON' + )}` + ) + return this.SKIP + } + this.addLog( + 'checking Python explicitly set from environment ' + + 'variable PYTHON' + ) + this.addLog( + `${colorizeOutput( + GREEN, + 'process.env.PYTHON' + )} is "${colorizeOutput(GREEN, this.env.PYTHON)}"` + ) + }, + checkFunc: this.checkCommand, + arg: this.env.PYTHON, + // name used as very short description + name: 'process.env.PYTHON' + }, + { + checkFunc: this.checkCommand, + name: 'python3', + arg: 'python3' + }, + { + checkFunc: this.checkCommand, + name: 'python', + arg: 'python' + }, + { + checkFunc: this.checkCommand, + name: 'python2', + arg: 'python2' + } + ] + + if (this.win) { + for (let i = 0; i < this.winDefaultLocations.length; ++i) { + const location = this.winDefaultLocations[i] + checks.push({ + before: () => { + this.addLog( + `checking if Python is ${colorizeOutput(GREEN, location)}` + ) + }, + checkFunc: this.checkExecPath, + arg: location + }) + } + checks.push({ + before: () => { + this.addLog( + `checking if the ${colorizeOutput( + GREEN, + 'py launcher' + )} can be used to find Python` + ) + }, + checkFunc: this.checkPyLauncher, + name: 'py Launcher' + }) + } + + return checks + } + + /** + * Type for possible place where python is + * + * @typedef check + * @type {object} + * @property {(name: string) => number|void} [before] + * @property {function} checkFunc + * @property {*} [arg] + * @property {string} [name] + */ + + /** + * + * + * @private + * @argument {check[]} checks + */ + async runChecks (checks) { + // using this flag becouse Fail is happen when ALL checks fail + let fail = true + + for (const check of checks) { + if (check.before) { + const beforeResult = check.before.apply(this) + + // if pretask fail - skip + if (beforeResult === this.SKIP || beforeResult === this.FAIL) { + // ?optional + // TODO: write to result arr which tests is SKIPPED + continue + } + } + + try { + if (!check.before) { + this.addLog( + `checking if ${colorizeOutput( + GREEN, + check.name || check.arg + )} can be used` + ) + } + + this.log.verbose( + `executing "${colorizeOutput( + GREEN, + check.name || check.arg + )}" to get Python executable path` + ) + + const result = await check.checkFunc.apply(this, [check ? check.arg : null]) + fail = false + this.succeed(result.path, result.version) + + break + } catch (err) { + this.catchErrors(err, check) + } + } + + if (fail) { + this.fail() + } + } + + /** + * Check if command is a valid Python to use. + * Will exit the Python finder on success. + * If on Windows, run in a CMD shell to support BAT/CMD launchers. + * + * @private + * @argument {string} command command which will be executed in shell + * @returns {Promise} + */ + checkCommand (command) { + let exec = command + let args = this.argsExecutable + let shell = false + if (this.win) { + // Arguments have to be manually quoted to avoid bugs with spaces in pathes + exec = `"${exec}"` + args = args.map((a) => `"${a}"`) + shell = true + } + + return new Promise((resolve, reject) => { + this.run(exec, args, shell).then(this.checkExecPath).then(resolve).catch(reject) + }) + } + + /** + * Check if the py launcher can find a valid Python to use. + * Will exit the Python finder on success. + * Distributions of Python on Windows by default install with the "py.exe" + * Python launcher which is more likely to exist than the Python executable + * being in the $PATH. + * + * @private + * @returns {Promise} + */ + checkPyLauncher () { + return new Promise((resolve, reject) => { + this.run(this.pyLauncher, this.argsExecutable, false) + .then(this.checkExecPath) + .then(resolve) + .catch(reject) + }) + } + + /** + * + * Check if a getted path is correct and + * Python executable hase the correct version to use. + * + * @private + * @argument {string} execPath path to check + * @returns {Promise} + */ + checkExecPath (execPath) { + // Returning new Promise instead of forwarding existing + // to pass both path and version + return new Promise((resolve, reject) => { + this.log.verbose(`- executing "${execPath}" to get version`) + this.run(execPath, this.argsVersion, false) + .then((ver) => { + // ? may be better code for version check + // ? may be move log messgaes to catchError func + const range = new semver.Range(this.semverRange) + let valid = false + + try { + valid = range.test(ver) + // throw new Error("test error") + } catch (err) { + this.log.silly(`range.test() threw:\n${err.stack}`) + this.addLog( + `"${colorizeOutput(RED, execPath)}" does not have a valid version` + ) + this.addLog('is it a Python executable?') + + reject(err) + } + + if (!valid) { + this.addLog( + `version is ${colorizeOutput( + RED, + ver + )} - should be ${colorizeOutput(RED, this.semverRange)}` + ) + this.addLog( + colorizeOutput(RED, 'THIS VERSION OF PYTHON IS NOT SUPPORTED') + ) + // object with error passed for conveniences + // (becouse we can also pass stderr or some additional staff) + // eslint-disable-next-line prefer-promise-reject-errors + reject({ err: new Error(`Found unsupported Python version ${ver}`) }) + } + + resolve({ path: execPath, version: ver }) + }) + .catch(reject) + }) + } + + /** + * Run an executable or shell command, trimming the output. + * + * @private + * @argument {string} exec command or path without arguments to execute + * @argument {string[]} args command args + * @argument {boolean} shell need be documented + * @returns {Promise} + */ + run (exec, args, shell) { + return new Promise( + /** + * @this {PythonFinder} + * @argument {function} resolve + * @argument {function} reject + */ + function (resolve, reject) { + const env = extend({}, this.env) + env.TERM = 'dumb' + const opts = { env: env, shell: shell } + + this.log.verbose( + `${colorizeOutput(GREEN, 'execFile')}: exec = %j`, + exec + ) + this.log.verbose( + `${colorizeOutput(GREEN, 'execFile')}: args = %j`, + args + ) + this.log.silly('execFile: opts = ', JSON.stringify(opts, null, 2)) + + //* assume that user use utf8 compatible termnal + + //* prosible outcomes with error messages (err.message, error.stack, stderr) + //! on windows: + // issue of encoding (garbage in terminal ) when 866 or any other locale code + // page is setted + // possible solutions: + // use "cmd" command with flag "/U" and "/C" (for more informatiom help cmd) + // which "Causes the output of + // internal commands to a pipe or file to be Unicode" (utf16le) + //* note: find-python-script.py send output in utf8 then may become necessary + //* to reencoded string with Buffer.from(stderr).toString() or something + //* similar (if needed) + // for this solution all args should be passed as SINGLE string in quotes + // becouse cmd has such signature: CMD [/A | /U] [/Q] [/D] [/E:ON | /E:OFF] + // [/F:ON | /F:OFF] [/V:ON | /V:OFF] [[/S] [/C | /K] string] + //* all pathes/commands and each argument must be in quotes becouse if they have + //* spaces they will broke everything + this.execFile(exec, args, opts, execFileCallback.bind(this)) + + /** + * + * @param {Error} err + * @param {string} stdout + * @param {string} stderr + */ + function execFileCallback (err, stdout, stderr) { + this.log.silly( + `${colorizeOutput(RED, 'execFile result')}: err =`, + (err && err.stack) || err + ) + + // executed script shouldn't pass anythong to stderr if successful + if (err || stderr) { + reject({ err: err || null, stderr: stderr || null }) + } else { + // trim function removing endings which couse bugs when comparing strings + const stdoutTrimed = stdout.trim() + resolve(stdoutTrimed) + } + } + }.bind(this) + ) + } + + /** + * Main error handling function in module + * Promises should throw errors up to this function + * Also used for logging + * + * @private + * TODO: figure out err type + * @param {{err: Error, stderr: string}} error + * @param {check} check + */ + catchErrors (error, check) { + const { err, stderr } = error + + this.addLog(`${RED}FAIL: ${check.name || check.arg}`) + + // array of error codes (type of errors) that we handling + const catchedErrorsCods = ['ENOENT', 9009] + + // dont know type of terminal errors + // @ts-ignore + if (catchedErrorsCods.includes(err ? err.code : undefined)) { + // @ts-ignore + switch (err ? err.code : undefined) { + case 'ENOENT': + this.addLog( + `${colorizeOutput( + RED, + 'ERROR:' + // @ts-ignore + )} No such file or directory: ${colorizeOutput(RED, err.path)}` + ) + break + + case 9009: + this.addLog( + `${colorizeOutput( + RED, + 'ERROR:' + )} Command failed: file not found or not in PATH` + ) + break + } + } else { + this.addLog(`${colorizeOutput(RED, 'ERROR:')} ${err ? err.message : ''}`) + this.log.silly(err ? err.stack : '') + + if (stderr) { + this.addLog(`${colorizeOutput(RED, 'STDERR:')} ${stderr ? stderr.trim() : ''}`) + } + } + this.addLog('--------------------------------------------') + } + + /** + * Function which is called if python path founded + * + * @private + * @param {string} execPath founded path + * @param {string} version python version + */ + succeed (execPath, version) { + this.log.info( + `using Python version ${colorizeOutput( + GREEN, + version + )} found at "${colorizeOutput(GREEN, execPath)}"` + ) + process.nextTick(this.callback.bind(null, null, execPath)) + } + + /** + * @private + */ + fail () { + const errorLog = this.errorLog.map((str) => str.trim()).join('\n') + + const pathExample = this.win + ? 'C:\\Path\\To\\python.exe' + : '/path/to/pythonexecutable' + // For Windows 80 col console, use up to the column before the one marked + // with X (total 79 chars including logger prefix, 58 chars usable here): + // X + const info = [ + '**********************************************************', + 'If you have non-displayed characters set "UTF-8" encoding.', + 'You need to install the latest version of Python.', + 'Node-gyp should be able to find and use Python. If not,', + 'you can try one of the following options:', + `- Use the switch --python="${pathExample}"`, + ' (accepted by both node-gyp and npm)', + '- Set the environment variable PYTHON', + '- Set the npm configuration variable python:', + ` npm config set python "${pathExample}"`, + 'For more information consult the documentation at:', + 'https://github.com/nodejs/node-gyp#installation', + '**********************************************************' + ].join('\n') + + this.log.error(`\n${errorLog}\n\n${info}\n`) + process.nextTick( + this.callback.bind( + null, + // if changing error message dont forget also change it test file too + new Error('Could not find any Python installation to use') + ) + ) + } +} + +/** + * + * @param {string} configPython force setted from terminal or npm config python path + * @param {(err:Error, found:string)=> void} callback succsed/error callback from where result + * is available + */ +function findPython (configPython, callback) { + const finder = new PythonFinder(configPython, callback) + finder.findPython() +} + +// function for tests +/* findPython(null, (err, found) => { + console.log('found:', '\x1b[31m', found) + console.log('\x1b[0m') +}) + */ +module.exports = findPython +module.exports.test = { + PythonFinder: PythonFinder, + findPython: findPython +} diff --git a/test/test-find-python-script.js b/test/test-find-python-script.js new file mode 100644 index 0000000000..b1afd05b14 --- /dev/null +++ b/test/test-find-python-script.js @@ -0,0 +1,83 @@ +// @ts-check +'use strict' +/** @typedef {import("tap")} Tap */ + +const test = require('tap').test +const execFile = require('child_process').execFile +const path = require('path') + +require('npmlog').level = 'warn' + +//* can use name as short descriptions + +/** + * @typedef Check + * @property {string} path - path to execurtable or command + * @property {string} name - very little description + */ + +/** + * @type {Check[]} + */ +const checks = [ + { path: process.env.PYTHON, name: 'env var PYTHON' }, + { path: process.env.python2, name: 'env var python2' }, + { path: 'python3', name: 'env var python3' } +] +const args = [path.resolve('./lib/find-python-script.py')] +const options = { + windowsHide: true +} + +/** + Getting output from find-python-script.py, + compare it to path provided to terminal. + If equale - test pass + + runs for all checks + + @private + @argument {Error} err - exec error + @argument {string} stdout - stdout buffer of child process + @argument {string} stderr + @this {{t, exec: Check}} + */ +function check (err, stdout, stderr) { + const { t, exec } = this + if (!err && !stderr) { + t.strictEqual( + stdout.trim(), + exec.path, + `${exec.name}: check path ${exec.path} equals ${stdout.trim()}` + ) + } else { + // @ts-ignore + if (err.code === 9009) { + t.skip(`skipped: ${exec.name} file not found`) + } else { + t.fail(`error: ${err}\n\nstderr: ${stderr}`) + } + } +} + +test('find-python-script', (t) => { + t.plan(checks.length) + + // context for check functions + const ctx = { + t: t, + exec: {} + } + + for (const exec of checks) { + // checking if env var exist + if (!(exec.path === undefined || exec.path === null)) { + ctx.exec = exec + // passing ctx as coppied object to make properties immutable from here + const boundedCheck = check.bind(Object.assign({}, ctx)) + execFile(exec.path, args, options, boundedCheck) + } else { + t.skip(`skipped: ${exec.name} doesn't exist or unavailable`) + } + } +}) diff --git a/test/test-find-python.js b/test/test-find-python.js index 6be887f7eb..d9c0a50737 100644 --- a/test/test-find-python.js +++ b/test/test-find-python.js @@ -1,230 +1,228 @@ -'use strict' - -delete process.env.PYTHON - -const test = require('tap').test -const findPython = require('../lib/find-python') -const execFile = require('child_process').execFile -const PythonFinder = findPython.test.PythonFinder - -require('npmlog').level = 'warn' - -test('find python', function (t) { - t.plan(4) - - findPython.test.findPython(null, function (err, found) { - t.strictEqual(err, null) - var proc = execFile(found, ['-V'], function (err, stdout, stderr) { - t.strictEqual(err, null) - if (/Python 2/.test(stderr)) { - t.strictEqual(stdout, '') - t.ok(/Python 2/.test(stderr)) - } else { - t.ok(/Python 3/.test(stdout)) - t.strictEqual(stderr, '') - } - }) - proc.stdout.setEncoding('utf-8') - proc.stderr.setEncoding('utf-8') - }) -}) - -function poison (object, property) { - function fail () { - console.error(Error(`Property ${property} should not have been accessed.`)) - process.abort() - } - var descriptor = { - configurable: false, - enumerable: false, - get: fail, - set: fail - } - Object.defineProperty(object, property, descriptor) -} - -function TestPythonFinder () { - PythonFinder.apply(this, arguments) -} -TestPythonFinder.prototype = Object.create(PythonFinder.prototype) -// Silence npmlog - remove for debugging -TestPythonFinder.prototype.log = { - silly: () => {}, - verbose: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} -} -delete TestPythonFinder.prototype.env.NODE_GYP_FORCE_PYTHON - -test('find python - python', function (t) { - t.plan(6) - - var f = new TestPythonFinder('python', done) - f.execFile = function (program, args, opts, cb) { - f.execFile = function (program, args, opts, cb) { - poison(f, 'execFile') - t.strictEqual(program, '/path/python') - t.ok(/sys\.version_info/.test(args[1])) - cb(null, '2.7.15') - } - t.strictEqual(program, - process.platform === 'win32' ? '"python"' : 'python') - t.ok(/sys\.executable/.test(args[1])) - cb(null, '/path/python') - } - f.findPython() - - function done (err, python) { - t.strictEqual(err, null) - t.strictEqual(python, '/path/python') - } -}) - -test('find python - python too old', function (t) { - t.plan(2) - - var f = new TestPythonFinder(null, done) - f.execFile = function (program, args, opts, cb) { - if (/sys\.executable/.test(args[args.length - 1])) { - cb(null, '/path/python') - } else if (/sys\.version_info/.test(args[args.length - 1])) { - cb(null, '2.3.4') - } else { - t.fail() - } - } - f.findPython() - - function done (err) { - t.ok(/Could not find any Python/.test(err)) - t.ok(/not supported/i.test(f.errorLog)) - } -}) - -test('find python - no python', function (t) { - t.plan(2) - - var f = new TestPythonFinder(null, done) - f.execFile = function (program, args, opts, cb) { - if (/sys\.executable/.test(args[args.length - 1])) { - cb(new Error('not found')) - } else if (/sys\.version_info/.test(args[args.length - 1])) { - cb(new Error('not a Python executable')) - } else { - t.fail() - } - } - f.findPython() - - function done (err) { - t.ok(/Could not find any Python/.test(err)) - t.ok(/not in PATH/.test(f.errorLog)) - } -}) - -test('find python - no python2, no python, unix', function (t) { - t.plan(2) - - var f = new TestPythonFinder(null, done) - f.checkPyLauncher = t.fail - f.win = false - - f.execFile = function (program, args, opts, cb) { - if (/sys\.executable/.test(args[args.length - 1])) { - cb(new Error('not found')) - } else { - t.fail() - } - } - f.findPython() - - function done (err) { - t.ok(/Could not find any Python/.test(err)) - t.ok(/not in PATH/.test(f.errorLog)) - } -}) - -test('find python - no python, use python launcher', function (t) { - t.plan(3) - - var f = new TestPythonFinder(null, done) - f.win = true - - f.execFile = function (program, args, opts, cb) { - if (program === 'py.exe') { - t.notEqual(args.indexOf('-c'), -1) - return cb(null, 'Z:\\snake.exe') - } - if (/sys\.executable/.test(args[args.length - 1])) { - cb(new Error('not found')) - } else if (f.winDefaultLocations.includes(program)) { - cb(new Error('not found')) - } else if (/sys\.version_info/.test(args[args.length - 1])) { - if (program === 'Z:\\snake.exe') { - cb(null, '2.7.14') - } else { - t.fail() - } - } else { - t.fail() - } - } - f.findPython() - - function done (err, python) { - t.strictEqual(err, null) - t.strictEqual(python, 'Z:\\snake.exe') - } -}) - -test('find python - no python, no python launcher, good guess', function (t) { - t.plan(2) - - var re = /C:[\\/]Python37[\\/]python[.]exe/ - var f = new TestPythonFinder(null, done) - f.win = true - - f.execFile = function (program, args, opts, cb) { - if (program === 'py.exe') { - return cb(new Error('not found')) - } - if (/sys\.executable/.test(args[args.length - 1])) { - cb(new Error('not found')) - } else if (re.test(program) && - /sys\.version_info/.test(args[args.length - 1])) { - cb(null, '3.7.3') - } else { - t.fail() - } - } - f.findPython() - - function done (err, python) { - t.strictEqual(err, null) - t.ok(re.test(python)) - } -}) - -test('find python - no python, no python launcher, bad guess', function (t) { - t.plan(2) - - var f = new TestPythonFinder(null, done) - f.win = true - - f.execFile = function (program, args, opts, cb) { - if (/sys\.executable/.test(args[args.length - 1])) { - cb(new Error('not found')) - } else if (/sys\.version_info/.test(args[args.length - 1])) { - cb(new Error('not a Python executable')) - } else { - t.fail() - } - } - f.findPython() - - function done (err) { - t.ok(/Could not find any Python/.test(err)) - t.ok(/not in PATH/.test(f.errorLog)) - } -}) +'use strict' + +const test = require('tap').test +const findPython = require('../lib/find-python') +const execFile = require('child_process').execFile +const PythonFinder = findPython.test.PythonFinder + +const npmlog = require('npmlog') +npmlog.level = 'silent' + +// what chould final error message displayed in terminal contain +const finalErrorMessage = 'Could not find any Python' + +//! dont forget manually call pythonFinderInstance.findPython() + +// String emmulating path coomand or anything else with spaces +// and UTF-8 charcters. +// Is returned by execFile +//! USE FOR ALL STRINGS +const testString = 'python one loveā™„' +const testVersions = { + outdated: '2.0.0', + normal: '3.7.0', + testError: new Error('test error') +} + +/** + * @typedef OptionsObj + * @property {boolean} shouldProduceError + * @property {boolean} checkingPyLauncher + * @property {boolean} isPythonOutdated + * @property {boolean} checkingWinDefaultPathes + * + */ + +/** + * + * @param {OptionsObj} optionsObj + */ +function TestExecFile (optionsObj) { + /** + * + * @this {PythonFinder} + */ + return function testExecFile (exec, args, options, callback) { + if (!(optionsObj ? optionsObj.shouldProduceError : false)) { + if (args === this.argsVersion) { + if (optionsObj ? optionsObj.checkingWinDefaultPathes : false) { + if (this.winDefaultLocations.includes(exec)) { + callback(null, testVersions.normal) + } else { + callback(new Error('not found')) + } + } else if (optionsObj ? optionsObj.isPythonOutdated : false) { + callback(null, testVersions.outdated, null) + } else { + callback(null, testVersions.normal, null) + } + } else if (args === this.win ? `"${this.argsExecutable}"` : this.argsExecutable) { + if (optionsObj ? optionsObj.checkingPyLauncher : false) { + if (exec === 'py.exe' || exec === (this.win ? '"python"' : 'python')) { + callback(null, testString, null) + } else { + callback(new Error('not found')) + } + } else if (optionsObj ? optionsObj.checkingWinDefaultPathes : false) { + callback(new Error('not found')) + } else { + callback(null, testString, null) + } + } else { + throw new Error( + `invalid arguments are provided! provided args +are: ${args};\n\nValid are: \n${this.argsExecutable}\n${this.argsVersion}` + ) + } + } else { + const testError = new Error( + `test error ${testString}; optionsObj: ${optionsObj}` + ) + callback(testError) + } + } +} + +/** + * + * @param {boolean} isPythonOutdated if true will return outadet version of python + * @param {OptionsObj} optionsObj + */ + +test('new-find-python', { buffered: true }, (t) => { + t.test('whole module tests', (t) => { + t.test('python found', (t) => { + const pythonFinderInstance = new PythonFinder(null, (err, path) => { + if (err) { + t.fail( + `musn't produce any errors if execFile doesn't produced error. ${err}` + ) + } else { + t.strictEqual(path, testString) + t.end() + } + }) + pythonFinderInstance.execFile = TestExecFile() + + pythonFinderInstance.findPython() + }) + + t.test('outdated version of python found', (t) => { + const pythonFinderInstance = new PythonFinder(null, (err, path) => { + if (!err) { + t.fail("mustn't return path of outdated") + } else { + t.end() + } + }) + + pythonFinderInstance.execFile = TestExecFile({ isPythonOutdated: true }) + + pythonFinderInstance.findPython() + }) + + t.test('no python on computer', (t) => { + const pythonFinderInstance = new PythonFinder(null, (err, path) => { + t.ok(err.message.includes(finalErrorMessage)) + t.end() + }) + + pythonFinderInstance.execFile = TestExecFile({ + shouldProduceError: true + }) + + pythonFinderInstance.findPython() + }) + + t.test('no python2, no python, unix', (t) => { + const pythonFinderInstance = new PythonFinder(null, (err, path) => { + t.false(path) + + t.true(err) + t.ok(err.message.includes(finalErrorMessage)) + t.end() + }) + + pythonFinderInstance.win = false + pythonFinderInstance.checkPyLauncher = t.fail + + pythonFinderInstance.execFile = TestExecFile({ + shouldProduceError: true + }) + + pythonFinderInstance.findPython() + }) + + t.test('no python, use python launcher', (t) => { + const pythonFinderInstance = new PythonFinder(null, (err, path) => { + t.strictEqual(err, null) + + t.strictEqual(path, testString) + + t.end() + }) + + pythonFinderInstance.win = true + + pythonFinderInstance.execFile = TestExecFile({ + checkingPyLauncher: true + }) + + pythonFinderInstance.findPython() + }) + + t.test('no python, no python launcher, checking win default locations', (t) => { + const pythonFinderInstance = new PythonFinder(null, (err, path) => { + t.strictEqual(err, null) + t.true(pythonFinderInstance.winDefaultLocations.includes(path)) + t.end() + }) + + pythonFinderInstance.win = true + + pythonFinderInstance.execFile = TestExecFile({ checkingWinDefaultPathes: true }) + pythonFinderInstance.findPython() + }) + + t.test('python is setted from config', (t) => { + const pythonFinderInstance = new PythonFinder(testString, (err, path) => { + t.strictEqual(err, null) + + t.strictEqual(path, testString) + + t.end() + }) + + pythonFinderInstance.win = true + + pythonFinderInstance.execFile = TestExecFile() + pythonFinderInstance.findPython() + }) + + t.end() + }) + + t.test('real testing (trying to find real python exec)', (t) => { + const pythonFinderInstance = new PythonFinder(null, (err, path) => { + t.strictEqual(err, null) + + execFile(path, ['-V'], (err, stdout, stderr) => { + t.false(err) + + if (stderr.includes('Python 2')) { + t.strictEqual(stdout, '') + t.ok(stderr.includes('Python 2')) + } else { + t.ok(stderr.includes('Python 3')) + t.strictEqual(stderr, '') + } + + t.end() + }) + }) + + pythonFinderInstance.findPython() + }) + + t.end() +})