Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for more PyPy versions and installing them on-flight #168

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
340 changes: 326 additions & 14 deletions dist/index.js

Large diffs are not rendered by default.

116 changes: 116 additions & 0 deletions src/find-pypy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as path from 'path';
import * as pypyInstall from './install-pypy';
import {IS_WINDOWS} from './utils';

import * as semver from 'semver';
import * as core from '@actions/core';
import * as tc from '@actions/tool-cache';

interface IPyPyVersionSpec {
pypyVersion: string;
pythonVersion: string;
}

export async function findPyPyVersion(
versionSpec: string,
architecture: string
): Promise<{resolvedPyPyVersion: string; resolvedPythonVersion: string}> {
let resolvedPyPyVersion = '';
let resolvedPythonVersion = '';
let installDir: string | null;

const pypyVersionSpec = parsePyPyVersion(versionSpec);

// PyPy only precompiles binaries for x86, but the architecture parameter defaults to x64.
if (IS_WINDOWS && architecture === 'x64') {
architecture = 'x86';
}

({installDir, resolvedPythonVersion, resolvedPyPyVersion} = findPyPyToolCache(
pypyVersionSpec.pythonVersion,
pypyVersionSpec.pypyVersion,
architecture
));

if (!installDir) {
({
installDir,
resolvedPythonVersion,
resolvedPyPyVersion
} = await pypyInstall.installPyPy(
pypyVersionSpec.pypyVersion,
pypyVersionSpec.pythonVersion,
architecture
));
}

const pipDir = IS_WINDOWS ? 'Scripts' : 'bin';
const _binDir = path.join(installDir, pipDir);
const pythonLocation = pypyInstall.getPyPyBinaryPath(installDir);
core.exportVariable('pythonLocation', pythonLocation);
core.addPath(pythonLocation);
core.addPath(_binDir);
dmitry-shibanov marked this conversation as resolved.
Show resolved Hide resolved

return {resolvedPyPyVersion, resolvedPythonVersion};
}

function findPyPyToolCache(
pythonVersion: string,
pypyVersion: string,
architecture: string
) {
let resolvedPyPyVersion = '';
let resolvedPythonVersion = '';
let installDir: string | null = tc.find('PyPy', pythonVersion, architecture);

if (installDir) {
// 'tc.find' finds tool based on Python version but we also need to check
// whether PyPy version satisfies requested version.
resolvedPythonVersion = getPyPyVersionFromPath(installDir);
resolvedPyPyVersion = pypyInstall.readExactPyPyVersion(installDir);

const isPyPyVersionSatisfies = semver.satisfies(
resolvedPyPyVersion,
pypyVersion
);
if (!isPyPyVersionSatisfies) {
installDir = null;
resolvedPyPyVersion = '';
resolvedPythonVersion = '';
}
}

if (!installDir) {
core.info(
`PyPy version ${pythonVersion} (${pypyVersion}) was not found in the local cache`
);
}

return {installDir, resolvedPythonVersion, resolvedPyPyVersion};
}

function parsePyPyVersion(versionSpec: string): IPyPyVersionSpec {
const versions = versionSpec.split('-').filter(item => !!item);

if (versions.length < 2) {
throw new Error(
"Invalid 'version' property for PyPy. PyPy version should be specified as 'pypy-<python-version>'. See readme for more examples."
);
konradpabjan marked this conversation as resolved.
Show resolved Hide resolved
}
const pythonVersion = versions[1];
dmitry-shibanov marked this conversation as resolved.
Show resolved Hide resolved
let pypyVersion: string;
if (versions.length > 2) {
pypyVersion = pypyInstall.pypyVersionToSemantic(versions[2]);
} else {
pypyVersion = 'x';
}

return {
pypyVersion: pypyVersion,
pythonVersion: pythonVersion
};
}

function getPyPyVersionFromPath(installDir: string) {
dmitry-shibanov marked this conversation as resolved.
Show resolved Hide resolved
return path.basename(path.dirname(installDir));
}
4 changes: 1 addition & 3 deletions src/find-python.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as os from 'os';
import * as path from 'path';
import {IS_WINDOWS, IS_LINUX} from './utils';

import * as semver from 'semver';

Expand All @@ -8,9 +9,6 @@ import * as installer from './install-python';
import * as core from '@actions/core';
import * as tc from '@actions/tool-cache';

const IS_WINDOWS = process.platform === 'win32';
const IS_LINUX = process.platform === 'linux';

// Python has "scripts" or "bin" directories where command-line tools that come with packages are installed.
// This is where pip is, along with anything that pip installs.
// There is a seperate directory for `pip install --user`.
Expand Down
216 changes: 216 additions & 0 deletions src/install-pypy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import * as path from 'path';
import * as core from '@actions/core';
import * as tc from '@actions/tool-cache';
import * as semver from 'semver';
import * as httpm from '@actions/http-client';
import * as exec from '@actions/exec';
import * as fs from 'fs';

import {IS_WINDOWS, IPyPyManifestRelease, createSymlinkInFolder} from './utils';

const PYPY_VERSION_FILE = 'PYPY_VERSION';

export async function installPyPy(
pypyVersion: string,
pythonVersion: string,
architecture: string
) {
let downloadDir;

const releases = await getAvailablePyPyVersions();
const releaseData = findRelease(
releases,
pythonVersion,
pypyVersion,
architecture
);

if (!releaseData || !releaseData.foundAsset) {
throw new Error(
`PyPy version ${pythonVersion} (${pypyVersion}) with arch ${architecture} not found`
);
}

const {foundAsset, resolvedPythonVersion, resolvedPyPyVersion} = releaseData;
let downloadUrl = `${foundAsset.download_url}`;

core.info(`Downloading PyPy from "${downloadUrl}" ...`);
const pypyPath = await tc.downloadTool(downloadUrl);
dmitry-shibanov marked this conversation as resolved.
Show resolved Hide resolved

core.info('Extracting downloaded archive...');
if (IS_WINDOWS) {
downloadDir = await tc.extractZip(pypyPath);
} else {
downloadDir = await tc.extractTar(pypyPath, undefined, 'x');
}

// root folder in archive can have unpredictable name so just take the first folder
// downloadDir is unique folder under TEMP and can't contain any other folders
const archiveName = fs.readdirSync(downloadDir)[0];

const toolDir = path.join(downloadDir, archiveName);
let installDir = toolDir;
if (!isNightlyKeyword(resolvedPyPyVersion)) {
installDir = await tc.cacheDir(
toolDir,
'PyPy',
resolvedPythonVersion,
architecture
);
}

writeExactPyPyVersionFile(installDir, resolvedPyPyVersion);

const binaryPath = getPyPyBinaryPath(installDir);
await createPyPySymlink(binaryPath, resolvedPythonVersion);
await installPip(binaryPath);

return {installDir, resolvedPythonVersion, resolvedPyPyVersion};
}

async function getAvailablePyPyVersions() {
const url = 'https://downloads.python.org/pypy/versions.json';
const http: httpm.HttpClient = new httpm.HttpClient('tool-cache');

const response = await http.getJson<IPyPyManifestRelease[]>(url);
if (!response.result) {
throw new Error(
`Unable to retrieve the list of available PyPy versions from '${url}'`
);
}

return response.result;
}

async function createPyPySymlink(
pypyBinaryPath: string,
pythonVersion: string
) {
const version = semver.coerce(pythonVersion)!;
const pythonBinaryPostfix = semver.major(version);
const pypyBinaryPostfix = pythonBinaryPostfix === 2 ? '' : '3';
let binaryExtension = IS_WINDOWS ? '.exe' : '';

core.info('Creating symlinks...');
createSymlinkInFolder(
pypyBinaryPath,
`pypy${pypyBinaryPostfix}${binaryExtension}`,
`python${pythonBinaryPostfix}${binaryExtension}`,
true
);

createSymlinkInFolder(
pypyBinaryPath,
`pypy${pypyBinaryPostfix}${binaryExtension}`,
`python${binaryExtension}`,
true
);
}

async function installPip(pythonLocation: string) {
core.info('Installing and updating pip');
const pythonBinary = path.join(pythonLocation, 'python');
await exec.exec(`${pythonBinary} -m ensurepip`);
// TO-DO should we skip updating of pip ?
await exec.exec(
dmitry-shibanov marked this conversation as resolved.
Show resolved Hide resolved
`${pythonLocation}/python -m pip install --ignore-installed pip`
);
}

function findRelease(
releases: IPyPyManifestRelease[],
pythonVersion: string,
pypyVersion: string,
architecture: string
) {
const filterReleases = releases.filter(item => {
const isPythonVersionSatisfies = semver.satisfies(
dmitry-shibanov marked this conversation as resolved.
Show resolved Hide resolved
semver.coerce(item.python_version)!,
pythonVersion
);
const isPyPyNightly =
isNightlyKeyword(pypyVersion) && isNightlyKeyword(item.pypy_version);
const isPyPyVersionSatisfies =
isPyPyNightly ||
semver.satisfies(pypyVersionToSemantic(item.pypy_version), pypyVersion);
const isArchExists = item.files.some(
file => file.arch === architecture && file.platform === process.platform
);
return isPythonVersionSatisfies && isPyPyVersionSatisfies && isArchExists;
});

if (filterReleases.length === 0) {
return null;
}

const sortedReleases = filterReleases.sort((previous, current) => {
return (
semver.compare(
semver.coerce(pypyVersionToSemantic(current.pypy_version))!,
semver.coerce(pypyVersionToSemantic(previous.pypy_version))!
) ||
semver.compare(
semver.coerce(current.python_version)!,
semver.coerce(previous.python_version)!
)
);
});

const foundRelease = sortedReleases[0];
const foundAsset = foundRelease.files.find(
item => item.arch === architecture && item.platform === process.platform
);

return {
foundAsset,
resolvedPythonVersion: foundRelease.python_version,
resolvedPyPyVersion: foundRelease.pypy_version
};
}

// helper functions

/**
* In tool-cache, we put PyPy to '<toolcache_root>/PyPy/<python_version>/x64'
* There is no easy way to determine what PyPy version is located in specific folder
* 'pypy --version' is not reliable enough since it is not set properly for preview versions
* "7.3.3rc1" is marked as '7.3.3' in 'pypy --version'
* so we put PYPY_VERSION file to PyPy directory when install it to VM and read it when we need to know version
* PYPY_VERSION contains exact version from 'versions.json'
*/
export function readExactPyPyVersion(installDir: string) {
let pypyVersion = '';
let fileVersion = path.join(installDir, PYPY_VERSION_FILE);
if (fs.existsSync(fileVersion)) {
pypyVersion = fs.readFileSync(fileVersion).toString();
core.debug(`Version from ${PYPY_VERSION_FILE} file is ${pypyVersion}`);
}

return pypyVersion;
}

function writeExactPyPyVersionFile(
installDir: string,
resolvedPyPyVersion: string
) {
const pypyFilePath = path.join(installDir, PYPY_VERSION_FILE);
fs.writeFileSync(pypyFilePath, resolvedPyPyVersion);
}

/** Get PyPy binary location from the tool of installation directory
* - On Linux and macOS, the Python interpreter is in 'bin'.
* - On Windows, it is in the installation root.
*/
export function getPyPyBinaryPath(installDir: string) {
const _binDir = path.join(installDir, 'bin');
return IS_WINDOWS ? installDir : _binDir;
}

function isNightlyKeyword(pypyVersion: string) {
return pypyVersion === 'nightly';
}

export function pypyVersionToSemantic(versionSpec: string) {
dmitry-shibanov marked this conversation as resolved.
Show resolved Hide resolved
const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc))(\d*)/g;
return versionSpec.replace(prereleaseVersion, '$1-$2.$3');
}
5 changes: 1 addition & 4 deletions src/install-python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as core from '@actions/core';
import * as tc from '@actions/tool-cache';
import * as exec from '@actions/exec';
import {ExecOptions} from '@actions/exec/lib/interfaces';
import {stderr} from 'process';
import {IS_WINDOWS, IS_LINUX} from './utils';

const TOKEN = core.getInput('token');
const AUTH = !TOKEN || isGhes() ? undefined : `token ${TOKEN}`;
Expand All @@ -12,9 +12,6 @@ const MANIFEST_REPO_NAME = 'python-versions';
const MANIFEST_REPO_BRANCH = 'main';
export const MANIFEST_URL = `https://raw.githubusercontent.com/${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}/${MANIFEST_REPO_BRANCH}/versions-manifest.json`;

const IS_WINDOWS = process.platform === 'win32';
const IS_LINUX = process.platform === 'linux';

export async function findReleaseFromManifest(
semanticVersionSpec: string,
architecture: string
Expand Down
Loading