Skip to content

Commit

Permalink
feat: install python if missing on Windows (react-native-community#1040)
Browse files Browse the repository at this point in the history
* feat: install python if missing on Windows

Fix react-native-community#979

* feat: add `fetchToTemp` method

Add `fetchToTem` method that downloads a given URL into the OS's temp
folder.

Also bump `node-fetch` to solve an issue with content-length not being
correctly streamed.

* chore: pr feedback

s 52999ec

* chore: limit python version

* chore: use logger instead of console

* chore: tests

* simplify

Co-authored-by: Michał Pierzchała <[email protected]>
  • Loading branch information
molant and thymikee authored Mar 13, 2020
1 parent 47b3dd9 commit 672ffc0
Show file tree
Hide file tree
Showing 14 changed files with 268 additions and 13 deletions.
26 changes: 25 additions & 1 deletion packages/cli/src/commands/doctor/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
HealthCheckCategory,
HealthCheckCategoryResult,
HealthCheckResult,
HealthCheckInterface,
} from './types';
import getEnvironmentInfo from '../../tools/envinfo';
import {logMessage} from './healthchecks/common';
Expand Down Expand Up @@ -89,6 +90,26 @@ type FlagsT = {
contributor: boolean | void;
};

/**
* Given a `healthcheck` and a `platform`, returns the specific fix for
* it or the fallback one if there is not one (`runAutomaticFix`).
*/
const getAutomaticFixForPlatform = (
healthcheck: HealthCheckInterface,
platform: NodeJS.Platform,
) => {
switch (platform) {
case 'win32':
return healthcheck.win32AutomaticFix || healthcheck.runAutomaticFix;
case 'darwin':
return healthcheck.darwinAutomaticFix || healthcheck.runAutomaticFix;
case 'linux':
return healthcheck.linuxAutomaticFix || healthcheck.runAutomaticFix;
default:
return healthcheck.runAutomaticFix;
}
};

export default (async (_, __, options) => {
const Loader = getLoader();
const loader = new Loader();
Expand Down Expand Up @@ -126,7 +147,10 @@ export default (async (_, __, options) => {
versions,
versionRange,
description: healthcheck.description,
runAutomaticFix: healthcheck.runAutomaticFix,
runAutomaticFix: getAutomaticFixForPlatform(
healthcheck,
process.platform,
),
isRequired,
type: needsToBeFixed
? isWarning
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import execa from 'execa';
import python from '../python';
import getEnvironmentInfo from '../../../../tools/envinfo';
import {EnvironmentInfo} from '../../types';
import {NoopLoader} from '../../../../tools/loader';
import * as common from '../common';

jest.mock('execa');
jest.mock('@react-native-community/cli-tools', () => ({
fetchToTemp: jest.fn(),
}));

const logSpy = jest.spyOn(common, 'logManualInstallation');

describe('python', () => {
let environmentInfo: EnvironmentInfo;

beforeAll(async () => {
environmentInfo = await getEnvironmentInfo();
}, 15000);

afterEach(() => {
jest.resetAllMocks();
});

it('returns a message if Python is not installed', async () => {
environmentInfo.Languages.Python = 'Not Found';
((execa as unknown) as jest.Mock).mockResolvedValue({stdout: ''});
const diagnostics = await python.getDiagnostics(environmentInfo);
expect(diagnostics.needsToBeFixed).toBe(true);
});

it('returns false if Python version is in range', async () => {
// @ts-ignore
environmentInfo.Languages.Python = {
version: '2.7.17',
};

const diagnostics = await python.getDiagnostics(environmentInfo);
expect(diagnostics.needsToBeFixed).toBe(false);
});

// envinfo has a special field for reporting Python 3 so no need to check for python 3

it('logs manual installation steps to the screen for the default fix', async () => {
const loader = new NoopLoader();
await python.runAutomaticFix({loader, environmentInfo});
expect(logSpy).toHaveBeenCalledTimes(1);
});

it('downloads and executes the installation on Windows when missing', async () => {
const loader = new NoopLoader();
const loaderSucceedSpy = jest.spyOn(loader, 'succeed');
const loaderFailSpy = jest.spyOn(loader, 'fail');

await python.win32AutomaticFix({loader, environmentInfo});

expect(loaderFailSpy).toHaveBeenCalledTimes(0);
expect(logSpy).toHaveBeenCalledTimes(0);
expect(loaderSucceedSpy).toBeCalledWith('Python installed successfully');
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/commands/doctor/healthchecks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import nodeJS from './nodeJS';
import {yarn, npm} from './packageManagers';
import python from './python';
import watchman from './watchman';
import androidHomeEnvVariable from './androidHomeEnvVariable';
import androidSDK from './androidSDK';
Expand Down Expand Up @@ -27,6 +28,7 @@ export const getHealthchecks = ({contributor}: Options): Healthchecks => ({
yarn,
npm,
...(process.platform === 'darwin' ? [watchman] : []),
...(process.platform === 'win32' ? [python] : []),
],
},
android: {
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/commands/doctor/healthchecks/python.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {fetchToTemp} from '@react-native-community/cli-tools';
import versionRanges from '../versionRanges';
import {doesSoftwareNeedToBeFixed} from '../checkInstallation';
import {logManualInstallation} from './common';
import {HealthCheckInterface} from '../types';

import {updateEnvironment} from '../../../tools/environmentVariables';
import {join} from 'path';
import {Ora} from 'ora';
import {executeCommand} from '../../../tools/executeWinCommand';

export default {
label: 'Python',
getDiagnostics: async ({Languages}) => ({
needsToBeFixed: doesSoftwareNeedToBeFixed({
version:
typeof Languages.Python === 'string'
? Languages.Python
: Languages.Python.version,
versionRange: versionRanges.PYTHON,
}),

version:
typeof Languages.Python === 'string'
? Languages.Python
: Languages.Python.version,
versionRange: versionRanges.PYTHON,
}),
win32AutomaticFix: async ({loader}: {loader: Ora}) => {
try {
const arch = process.arch === 'x64' ? 'amd64.' : '';
const installerUrl = `https://www.python.org/ftp/python/2.7.9/python-2.7.9.${arch}msi`;
const installPath = join(process.env.LOCALAPPDATA || '', 'python2');

loader.start(`Downloading Python installer from "${installerUrl}"`);

const installer = await fetchToTemp(installerUrl);

loader.text = `Installing Python in "${installPath}"`;
const command = `msiexec.exe /i "${installer}" TARGETDIR="${installPath}" /qn /L*P "python-log.txt"`;

await executeCommand(command);

loader.text = 'Updating environment variables';

await updateEnvironment('PATH', installPath);
await updateEnvironment('PATH', join(installPath, 'scripts'));

loader.succeed('Python installed successfully');
} catch (e) {
loader.fail(e);
}
},
runAutomaticFix: async () => {
/**
* Python is only needed on Windows so this method should never be called.
* Leaving it in case that changes and as an example of how to have a
* fallback.
*/
logManualInstallation({
healthcheck: 'Python',
url: 'https://www.python.org/downloads/',
});
},
} as HealthCheckInterface;
11 changes: 11 additions & 0 deletions packages/cli/src/commands/doctor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export type EnvironmentInfo = {
path: string;
};
};
Languages: {
Python:
| {
version: string;
path: string;
}
| 'Not Found';
};
};

export type HealthCheckCategory = {
Expand Down Expand Up @@ -95,6 +103,9 @@ export type HealthCheckInterface = {
versionRange?: string;
needsToBeFixed: boolean | string;
}>;
win32AutomaticFix?: RunAutomaticFix;
darwinAutomaticFix?: RunAutomaticFix;
linuxAutomaticFix?: RunAutomaticFix;
runAutomaticFix: RunAutomaticFix;
};

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/doctor/versionRanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default {
YARN: '>= 1.10.x',
NPM: '>= 4.x',
WATCHMAN: '4.x',
PYTHON: '>= 2.x < 3',
// Android
ANDROID_SDK: '>= 26.x',
ANDROID_NDK: '>= 19.x',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/tools/envinfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ async function getEnvironmentInfo(
System: ['OS', 'CPU', 'Memory', 'Shell'],
Binaries: ['Node', 'Yarn', 'npm', 'Watchman'],
IDEs: ['Xcode', 'Android Studio'],
Languages: ['Python'],
SDKs: ['iOS SDK', 'Android SDK'],
npmPackages: ['react', 'react-native', '@react-native-community/cli'],
npmGlobalPackages: ['*react-native*'],
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/tools/environmentVariables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {executeCommand} from './executeWinCommand';

/**
* Creates a new variable in the user's environment
*/
const setEnvironment = async (variable: string, value: string) => {
// https://superuser.com/a/601034
const command = `setx ${variable} "${value}"`;
return executeCommand(command);
};

/**
* Prepends the given `value` to the user's environment `variable`.
* @param {string} variable The environment variable to modify
* @param {string} value The value to add to the variable
* @returns {Promise<void>}
*/
const updateEnvironment = (variable: string, value: string) => {
// https://superuser.com/a/601034
const command = `for /f "skip=2 tokens=3*" %a in ('reg query HKCU\\Environment /v ${variable}') do @if [%b]==[] ( @setx ${variable} "${value};%~a" ) else ( @setx ${variable} "${value};%~a %~b" )
`;

return executeCommand(command);
};

export {setEnvironment, updateEnvironment};
14 changes: 14 additions & 0 deletions packages/cli/src/tools/executeWinCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import execa from 'execa';

/**
* Executes the given `command` on a shell taking care of slicing the parameters
* if needed.
*/
const executeShellCommand = async (command: string) => {
const args = command.split(' ');
const program = args.shift()!;

await execa(program, args, {shell: true});
};

export {executeShellCommand as executeCommand};
4 changes: 2 additions & 2 deletions packages/tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
"chalk": "^3.0.0",
"lodash": "^4.17.15",
"mime": "^2.4.1",
"node-fetch": "^2.5.0"
"node-fetch": "^2.6.0"
},
"devDependencies": {
"@types/lodash": "^4.14.149",
"@types/mime": "^2.0.1",
"@types/node-fetch": "^2.3.3"
"@types/node-fetch": "^2.5.5"
},
"files": [
"build",
Expand Down
44 changes: 41 additions & 3 deletions packages/tools/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';

import nodeFetch, {
RequestInit as FetchOptions,
Response,
Request,
Headers,
} from 'node-fetch';
import {CLIError} from './errors';
import logger from './logger';

async function unwrapFetchResult(response: Response) {
const data = await response.text();
Expand All @@ -16,10 +21,41 @@ async function unwrapFetchResult(response: Response) {
}
}

export default async function fetch(
/**
* Downloads the given `url` to the OS's temp folder and
* returns the path to it.
*/
const fetchToTemp = (url: string) => {
try {
return new Promise((resolve, reject) => {
const fileName = path.basename(url);
const tmpDir = path.join(os.tmpdir(), fileName);

nodeFetch(url).then(result => {
if (result.status >= 400) {
return reject(`Fetch request failed with status ${result.status}`);
}

const dest = fs.createWriteStream(tmpDir);
result.body.pipe(dest);

result.body.on('end', () => {
resolve(tmpDir);
});

result.body.on('error', reject);
});
});
} catch (e) {
logger.error(e);
throw e;
}
};

const fetch = async (
url: string | Request,
options?: FetchOptions,
): Promise<{status: number; data: any; headers: Headers}> {
): Promise<{status: number; data: any; headers: Headers}> => {
const result = await nodeFetch(url, options);
const data = await unwrapFetchResult(result);

Expand All @@ -34,4 +70,6 @@ export default async function fetch(
headers: result.headers,
data,
};
}
};

export {fetch, fetchToTemp};
2 changes: 1 addition & 1 deletion packages/tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ export {default as logger} from './logger';
export {default as groupFilesByType} from './groupFilesByType';
export {default as isPackagerRunning} from './isPackagerRunning';
export {default as getDefaultUserTerminal} from './getDefaultUserTerminal';
export {default as fetch} from './fetch';
export {fetch, fetchToTemp} from './fetch';

export * from './errors';
2 changes: 1 addition & 1 deletion packages/tools/src/isPackagerRunning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*
*/

import fetch from './fetch';
import {fetch} from './fetch';

/**
* Indicates whether or not the packager is running. It returns a promise that
Expand Down
Loading

0 comments on commit 672ffc0

Please sign in to comment.