Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 10 additions & 20 deletions .licenses/npm/@actions/io.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 10 additions & 20 deletions .licenses/npm/@actions/tool-cache.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 0 additions & 32 deletions .licenses/npm/@azure/core-asynciterator-polyfill.dep.yml

This file was deleted.

2 changes: 1 addition & 1 deletion .licenses/npm/@azure/core-auth.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .licenses/npm/@azure/core-http.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .licenses/npm/@azure/core-lro.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .licenses/npm/@azure/core-paging.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .licenses/npm/@azure/core-util.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .licenses/npm/@azure/logger.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .licenses/npm/@azure/storage-blob.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions .licenses/npm/@fastify/busboy.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 5 additions & 11 deletions .licenses/npm/@opentelemetry/api.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .licenses/npm/@types/node-fetch.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .licenses/npm/@types/node.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 0 additions & 33 deletions .licenses/npm/form-data-3.0.1.dep.yml

This file was deleted.

43 changes: 22 additions & 21 deletions .licenses/npm/mime-db.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions .licenses/npm/mime-types.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .licenses/npm/node-fetch.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions .licenses/npm/sax.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 0 additions & 31 deletions .licenses/npm/tslib-2.3.1.dep.yml

This file was deleted.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions .licenses/npm/undici-types.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions .licenses/npm/undici.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 15 additions & 4 deletions README.md
Expand Up @@ -18,7 +18,7 @@ See [action.yml](action.yml)
**Python**
```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.10'
Expand All @@ -28,12 +28,23 @@ steps:
**PyPy**
```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: 'pypy3.9'
- run: python my_script.py
```

**GraalPy**
```yaml
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: 'graalpy-22.3'
- run: python my_script.py
```

The `python-version` input is optional. If not supplied, the action will try to resolve the version from the default `.python-version` file. If the `.python-version` file doesn't exist Python or PyPy version from the PATH will be used. The default version of Python or PyPy in PATH varies between runners and can be changed unexpectedly so we recommend always setting Python version explicitly using the `python-version` or `python-version-file` inputs.

The action will first check the local [tool cache](docs/advanced-usage.md#hosted-tool-cache) for a [semver](https://github.com/npm/node-semver#versions) match. If unable to find a specific version in the tool cache, the action will attempt to download a version of Python from [GitHub Releases](https://github.com/actions/python-versions/releases) and for PyPy from the official [PyPy's dist](https://downloads.python.org/pypy/).
Expand Down Expand Up @@ -62,7 +73,7 @@ The action defaults to searching for a dependency file (`requirements.txt` or `p

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.9'
Expand All @@ -82,7 +93,7 @@ See examples of using `cache` and `cache-dependency-path` for `pipenv` and `poet
- [Check latest version](docs/advanced-usage.md#check-latest-version)
- [Caching packages](docs/advanced-usage.md#caching-packages)
- [Outputs and environment variables](docs/advanced-usage.md#outputs-and-environment-variables)
- [Available versions of Python and PyPy](docs/advanced-usage.md#available-versions-of-python-and-pypy)
- [Available versions of Python and PyPy](advanced-usage.md#available-versions-of-python-pypy-and-graalpy)
- [Hosted tool cache](docs/advanced-usage.md#hosted-tool-cache)
- [Using `setup-python` with a self-hosted runner](docs/advanced-usage.md#using-setup-python-with-a-self-hosted-runner)
- [Using `setup-python` on GHES](docs/advanced-usage.md#using-setup-python-on-ghes)
Expand Down
5,798 changes: 5,798 additions & 0 deletions __tests__/data/graalpy.json

Large diffs are not rendered by default.

378 changes: 378 additions & 0 deletions __tests__/find-graalpy.test.ts
@@ -0,0 +1,378 @@
import fs from 'fs';

import {HttpClient} from '@actions/http-client';
import * as ifm from '@actions/http-client/lib/interfaces';
import * as tc from '@actions/tool-cache';
import * as exec from '@actions/exec';
import * as core from '@actions/core';

import * as path from 'path';
import * as semver from 'semver';

import * as finder from '../src/find-graalpy';
import {IGraalPyManifestRelease, IS_WINDOWS} from '../src/utils';

import manifestData from './data/graalpy.json';

const architecture = 'x64';

const toolDir = path.join(__dirname, 'runner', 'tools');
const tempDir = path.join(__dirname, 'runner', 'temp');

/* GraalPy doesn't have a windows release yet */
const describeSkipOnWindows = IS_WINDOWS ? describe.skip : describe;

describe('parseGraalPyVersion', () => {
it.each([
['graalpy-23', '23'],
['graalpy-23.0', '23.0'],
['graalpy23.0', '23.0']
])('%s -> %s', (input, expected) => {
expect(finder.parseGraalPyVersion(input)).toEqual(expected);
});

it.each(['', 'graalpy-', 'graalpy', 'p', 'notgraalpy-'])(
'throw on invalid input "%s"',
input => {
expect(() => finder.parseGraalPyVersion(input)).toThrow(
"Invalid 'version' property for GraalPy. GraalPy version should be specified as 'graalpy<python-version>' or 'graalpy-<python-version>'. See README for examples and documentation."
);
}
);
});

describe('findGraalPyToolCache', () => {
const actualGraalPyVersion = '23.0.0';
const graalpyPath = path.join('GraalPy', actualGraalPyVersion, architecture);
let tcFind: jest.SpyInstance;
let infoSpy: jest.SpyInstance;
let warningSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;
let addPathSpy: jest.SpyInstance;
let exportVariableSpy: jest.SpyInstance;
let setOutputSpy: jest.SpyInstance;

beforeEach(() => {
tcFind = jest.spyOn(tc, 'find');
tcFind.mockImplementation((toolname: string, pythonVersion: string) => {
const semverVersion = new semver.Range(pythonVersion);
return semver.satisfies(actualGraalPyVersion, semverVersion)
? graalpyPath
: '';
});

infoSpy = jest.spyOn(core, 'info');
infoSpy.mockImplementation(() => null);

warningSpy = jest.spyOn(core, 'warning');
warningSpy.mockImplementation(() => null);

debugSpy = jest.spyOn(core, 'debug');
debugSpy.mockImplementation(() => null);

addPathSpy = jest.spyOn(core, 'addPath');
addPathSpy.mockImplementation(() => null);

exportVariableSpy = jest.spyOn(core, 'exportVariable');
exportVariableSpy.mockImplementation(() => null);

setOutputSpy = jest.spyOn(core, 'setOutput');
setOutputSpy.mockImplementation(() => null);
});

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

it('GraalPy exists on the path and versions are satisfied', () => {
expect(finder.findGraalPyToolCache('23.0.0', architecture)).toEqual({
installDir: graalpyPath,
resolvedGraalPyVersion: actualGraalPyVersion
});
});

it('GraalPy exists on the path and versions are satisfied with semver', () => {
expect(finder.findGraalPyToolCache('23.0', architecture)).toEqual({
installDir: graalpyPath,
resolvedGraalPyVersion: actualGraalPyVersion
});
});

it("GraalPy exists on the path, but version doesn't match", () => {
expect(finder.findGraalPyToolCache('22.3', architecture)).toEqual({
installDir: '',
resolvedGraalPyVersion: ''
});
});
});

describeSkipOnWindows('findGraalPyVersion', () => {
let getBooleanInputSpy: jest.SpyInstance;
let warningSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;
let infoSpy: jest.SpyInstance;
let addPathSpy: jest.SpyInstance;
let exportVariableSpy: jest.SpyInstance;
let setOutputSpy: jest.SpyInstance;
let tcFind: jest.SpyInstance;
let spyExtractZip: jest.SpyInstance;
let spyExtractTar: jest.SpyInstance;
let spyHttpClient: jest.SpyInstance;
let spyExistsSync: jest.SpyInstance;
let spyExec: jest.SpyInstance;
let spySymlinkSync: jest.SpyInstance;
let spyDownloadTool: jest.SpyInstance;
let spyFsReadDir: jest.SpyInstance;
let spyCacheDir: jest.SpyInstance;
let spyChmodSync: jest.SpyInstance;
let spyCoreAddPath: jest.SpyInstance;
let spyCoreExportVariable: jest.SpyInstance;
const env = process.env;

beforeEach(() => {
getBooleanInputSpy = jest.spyOn(core, 'getBooleanInput');
getBooleanInputSpy.mockImplementation(() => false);

infoSpy = jest.spyOn(core, 'info');
infoSpy.mockImplementation(() => {});

warningSpy = jest.spyOn(core, 'warning');
warningSpy.mockImplementation(() => null);

debugSpy = jest.spyOn(core, 'debug');
debugSpy.mockImplementation(() => null);

addPathSpy = jest.spyOn(core, 'addPath');
addPathSpy.mockImplementation(() => null);

exportVariableSpy = jest.spyOn(core, 'exportVariable');
exportVariableSpy.mockImplementation(() => null);

setOutputSpy = jest.spyOn(core, 'setOutput');
setOutputSpy.mockImplementation(() => null);

jest.resetModules();
process.env = {...env};
tcFind = jest.spyOn(tc, 'find');
tcFind.mockImplementation((tool: string, version: string) => {
const semverRange = new semver.Range(version);
let graalpyPath = '';
if (semver.satisfies('23.0.0', semverRange)) {
graalpyPath = path.join(toolDir, 'GraalPy', '23.0.0', architecture);
}
return graalpyPath;
});

spyDownloadTool = jest.spyOn(tc, 'downloadTool');
spyDownloadTool.mockImplementation(() => path.join(tempDir, 'GraalPy'));

spyExtractZip = jest.spyOn(tc, 'extractZip');
spyExtractZip.mockImplementation(() => tempDir);

spyExtractTar = jest.spyOn(tc, 'extractTar');
spyExtractTar.mockImplementation(() => tempDir);

spyFsReadDir = jest.spyOn(fs, 'readdirSync');
spyFsReadDir.mockImplementation((directory: string) => ['GraalPyTest']);

spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
spyHttpClient.mockImplementation(
async (): Promise<ifm.TypedResponse<IGraalPyManifestRelease[]>> => {
const result = JSON.stringify(manifestData);
return {
statusCode: 200,
headers: {},
result: JSON.parse(result) as IGraalPyManifestRelease[]
};
}
);

spyExec = jest.spyOn(exec, 'exec');
spyExec.mockImplementation(() => undefined);

spySymlinkSync = jest.spyOn(fs, 'symlinkSync');
spySymlinkSync.mockImplementation(() => undefined);

spyExistsSync = jest.spyOn(fs, 'existsSync');
spyExistsSync.mockReturnValue(true);

spyCoreAddPath = jest.spyOn(core, 'addPath');

spyCoreExportVariable = jest.spyOn(core, 'exportVariable');
});

afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
jest.restoreAllMocks();
process.env = env;
});

it('found GraalPy in toolcache', async () => {
await expect(
finder.findGraalPyVersion(
'graalpy-23.0',
architecture,
true,
false,
false
)
).resolves.toEqual('23.0.0');
expect(spyCoreAddPath).toHaveBeenCalled();
expect(spyCoreExportVariable).toHaveBeenCalledWith(
'pythonLocation',
expect.anything()
);
expect(spyCoreExportVariable).toHaveBeenCalledWith(
'PKG_CONFIG_PATH',
expect.anything()
);
});

it('throw on invalid input format', async () => {
await expect(
finder.findGraalPyVersion('graalpy-x23', architecture, true, false, false)
).rejects.toThrow();
});

it('found and install successfully', async () => {
spyCacheDir = jest.spyOn(tc, 'cacheDir');
spyCacheDir.mockImplementation(() =>
path.join(toolDir, 'GraalPy', '23.0.0', architecture)
);
spyChmodSync = jest.spyOn(fs, 'chmodSync');
spyChmodSync.mockImplementation(() => undefined);
await expect(
finder.findGraalPyVersion(
'graalpy-23.0.0',
architecture,
true,
false,
false
)
).resolves.toEqual('23.0.0');
expect(spyCoreAddPath).toHaveBeenCalled();
expect(spyCoreExportVariable).toHaveBeenCalledWith(
'pythonLocation',
expect.anything()
);
expect(spyCoreExportVariable).toHaveBeenCalledWith(
'PKG_CONFIG_PATH',
expect.anything()
);
});

it('found and install successfully without environment update', async () => {
spyCacheDir = jest.spyOn(tc, 'cacheDir');
spyCacheDir.mockImplementation(() =>
path.join(toolDir, 'GraalPy', '23.0.0', architecture)
);
spyChmodSync = jest.spyOn(fs, 'chmodSync');
spyChmodSync.mockImplementation(() => undefined);
await expect(
finder.findGraalPyVersion(
'graalpy-23.0.0',
architecture,
false,
false,
false
)
).resolves.toEqual('23.0.0');
expect(spyCoreAddPath).not.toHaveBeenCalled();
expect(spyCoreExportVariable).not.toHaveBeenCalled();
});

it('throw if release is not found', async () => {
await expect(
finder.findGraalPyVersion(
'graalpy-19.0.0',
architecture,
true,
false,
false
)
).rejects.toThrow(
`GraalPy version 19.0.0 with arch ${architecture} not found`
);
});

it('check-latest enabled version found and used from toolcache', async () => {
await expect(
finder.findGraalPyVersion(
'graalpy-23.0.0',
architecture,
false,
true,
false
)
).resolves.toEqual('23.0.0');

expect(infoSpy).toHaveBeenCalledWith('Resolved as GraalPy 23.0.0');
});

it('check-latest enabled version found and install successfully', async () => {
spyCacheDir = jest.spyOn(tc, 'cacheDir');
spyCacheDir.mockImplementation(() =>
path.join(toolDir, 'GraalPy', '23.0.0', architecture)
);
spyChmodSync = jest.spyOn(fs, 'chmodSync');
spyChmodSync.mockImplementation(() => undefined);
await expect(
finder.findGraalPyVersion(
'graalpy-23.0.0',
architecture,
false,
true,
false
)
).resolves.toEqual('23.0.0');
expect(infoSpy).toHaveBeenCalledWith('Resolved as GraalPy 23.0.0');
});

it('check-latest enabled version is not found and used from toolcache', async () => {
tcFind.mockImplementationOnce((tool: string, version: string) => {
const semverRange = new semver.Range(version);
let graalpyPath = '';
if (semver.satisfies('22.3.4', semverRange)) {
graalpyPath = path.join(toolDir, 'GraalPy', '22.3.4', architecture);
}
return graalpyPath;
});
await expect(
finder.findGraalPyVersion(
'graalpy-22.3.4',
architecture,
false,
true,
false
)
).resolves.toEqual('22.3.4');

expect(infoSpy).toHaveBeenCalledWith(
'Failed to resolve GraalPy 22.3.4 from manifest'
);
});

it('found and install successfully, pre-release fallback', async () => {
spyCacheDir = jest.spyOn(tc, 'cacheDir');
spyCacheDir.mockImplementation(() =>
path.join(toolDir, 'GraalPy', '23.1', architecture)
);
spyChmodSync = jest.spyOn(fs, 'chmodSync');
spyChmodSync.mockImplementation(() => undefined);
await expect(
finder.findGraalPyVersion(
'graalpy23.1',
architecture,
false,
false,
false
)
).rejects.toThrow();
await expect(
finder.findGraalPyVersion('graalpy23.1', architecture, false, false, true)
).resolves.toEqual('23.1.0-a.1');
});
});
4 changes: 2 additions & 2 deletions __tests__/find-pypy.test.ts
Expand Up @@ -2,7 +2,7 @@ import fs from 'fs';

import * as utils from '../src/utils';
import {HttpClient} from '@actions/http-client';
import * as ifm from '@actions/http-client/interfaces';
import * as ifm from '@actions/http-client/lib/interfaces';
import * as tc from '@actions/tool-cache';
import * as exec from '@actions/exec';
import * as core from '@actions/core';
Expand Down Expand Up @@ -240,7 +240,7 @@ describe('findPyPyVersion', () => {

spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
spyHttpClient.mockImplementation(
async (): Promise<ifm.ITypedResponse<IPyPyManifestRelease[]>> => {
async (): Promise<ifm.TypedResponse<IPyPyManifestRelease[]>> => {
const result = JSON.stringify(manifestData);
return {
statusCode: 200,
Expand Down
256 changes: 256 additions & 0 deletions __tests__/install-graalpy.test.ts
@@ -0,0 +1,256 @@
import fs from 'fs';

import {HttpClient} from '@actions/http-client';
import * as ifm from '@actions/http-client/lib/interfaces';
import * as tc from '@actions/tool-cache';
import * as exec from '@actions/exec';
import * as core from '@actions/core';
import * as path from 'path';

import * as installer from '../src/install-graalpy';
import {
IGraalPyManifestRelease,
IGraalPyManifestAsset,
IS_WINDOWS
} from '../src/utils';

import manifestData from './data/graalpy.json';

const architecture = 'x64';

const toolDir = path.join(__dirname, 'runner', 'tools');
const tempDir = path.join(__dirname, 'runner', 'temp');

/* GraalPy doesn't have a windows release yet */
const describeSkipOnWindows = IS_WINDOWS ? describe.skip : describe;

describe('graalpyVersionToSemantic', () => {
it.each([
['23.0.0a1', '23.0.0a1'],
['23.0.0', '23.0.0'],
['23.0.x', '23.0.x'],
['23.x', '23.x']
])('%s -> %s', (input, expected) => {
expect(installer.graalPyTagToVersion(input)).toEqual(expected);
});
});

describeSkipOnWindows('findRelease', () => {
const result = JSON.stringify(manifestData);
const releases = JSON.parse(result) as IGraalPyManifestRelease[];
const extension = 'tar.gz';
const arch = installer.toGraalPyArchitecture(architecture);
const platform = installer.toGraalPyPlatform(process.platform);
const extensionName = `${platform}-${arch}.${extension}`;
const files: IGraalPyManifestAsset = {
name: `graalpython-23.0.0-${extensionName}`,
browser_download_url: `https://github.com/oracle/graalpython/releases/download/graal-23.0.0/graalpython-23.0.0-${extensionName}`
};
const filesRC1: IGraalPyManifestAsset = {
name: `graalpython-23.1.0a1-${extensionName}`,
browser_download_url: `https://github.com/oracle/graalpython/releases/download/graal-23.1.0a1/graalpython-23.1.0a1-${extensionName}`
};

let warningSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;
let infoSpy: jest.SpyInstance;

beforeEach(() => {
infoSpy = jest.spyOn(core, 'info');
infoSpy.mockImplementation(() => {});

warningSpy = jest.spyOn(core, 'warning');
warningSpy.mockImplementation(() => null);

debugSpy = jest.spyOn(core, 'debug');
debugSpy.mockImplementation(() => null);
});

it("GraalPy version doesn't match", () => {
const graalpyVersion = '12.0.0';
expect(
installer.findRelease(releases, graalpyVersion, architecture, false)
).toEqual(null);
});

it('GraalPy version matches', () => {
const graalpyVersion = '23.0.0';
expect(
installer.findRelease(releases, graalpyVersion, architecture, false)
).toMatchObject({
foundAsset: files,
resolvedGraalPyVersion: graalpyVersion
});
});

it('Preview version of GraalPy is found', () => {
const graalpyVersion = installer.graalPyTagToVersion('vm-23.1.0a1');
expect(
installer.findRelease(releases, graalpyVersion, architecture, false)
).toMatchObject({
foundAsset: {
name: `graalpython-23.1.0a1-${extensionName}`,
browser_download_url: `https://github.com/oracle/graalpython/releases/download/graal-23.1.0a1/graalpython-23.1.0a1-${extensionName}`
},
resolvedGraalPyVersion: '23.1.0-a.1'
});
});

it('Latest GraalPy is found', () => {
const graalpyVersion = 'x';
expect(
installer.findRelease(releases, graalpyVersion, architecture, false)
).toMatchObject({
foundAsset: files,
resolvedGraalPyVersion: '23.0.0'
});
});

it('GraalPy version matches semver (pre-release)', () => {
const graalpyVersion = '23.1.x';
expect(
installer.findRelease(releases, graalpyVersion, architecture, false)
).toBeNull();
expect(
installer.findRelease(releases, graalpyVersion, architecture, true)
).toMatchObject({
foundAsset: filesRC1,
resolvedGraalPyVersion: '23.1.0-a.1'
});
});
});

describeSkipOnWindows('installGraalPy', () => {
let tcFind: jest.SpyInstance;
let warningSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;
let infoSpy: jest.SpyInstance;
let spyExtractZip: jest.SpyInstance;
let spyExtractTar: jest.SpyInstance;
let spyFsReadDir: jest.SpyInstance;
let spyFsWriteFile: jest.SpyInstance;
let spyHttpClient: jest.SpyInstance;
let spyExistsSync: jest.SpyInstance;
let spyExec: jest.SpyInstance;
let spySymlinkSync: jest.SpyInstance;
let spyDownloadTool: jest.SpyInstance;
let spyCacheDir: jest.SpyInstance;
let spyChmodSync: jest.SpyInstance;

beforeEach(() => {
tcFind = jest.spyOn(tc, 'find');
tcFind.mockImplementation(() =>
path.join('GraalPy', '3.6.12', architecture)
);

spyDownloadTool = jest.spyOn(tc, 'downloadTool');
spyDownloadTool.mockImplementation(() => path.join(tempDir, 'GraalPy'));

spyExtractZip = jest.spyOn(tc, 'extractZip');
spyExtractZip.mockImplementation(() => tempDir);

spyExtractTar = jest.spyOn(tc, 'extractTar');
spyExtractTar.mockImplementation(() => tempDir);

infoSpy = jest.spyOn(core, 'info');
infoSpy.mockImplementation(() => {});

warningSpy = jest.spyOn(core, 'warning');
warningSpy.mockImplementation(() => null);

debugSpy = jest.spyOn(core, 'debug');
debugSpy.mockImplementation(() => null);

spyFsReadDir = jest.spyOn(fs, 'readdirSync');
spyFsReadDir.mockImplementation(() => ['GraalPyTest']);

spyFsWriteFile = jest.spyOn(fs, 'writeFileSync');
spyFsWriteFile.mockImplementation(() => undefined);

spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
spyHttpClient.mockImplementation(
async (): Promise<ifm.TypedResponse<IGraalPyManifestRelease[]>> => {
const result = JSON.stringify(manifestData);
return {
statusCode: 200,
headers: {},
result: JSON.parse(result) as IGraalPyManifestRelease[]
};
}
);

spyExec = jest.spyOn(exec, 'exec');
spyExec.mockImplementation(() => undefined);

spySymlinkSync = jest.spyOn(fs, 'symlinkSync');
spySymlinkSync.mockImplementation(() => undefined);

spyExistsSync = jest.spyOn(fs, 'existsSync');
spyExistsSync.mockImplementation(() => false);
});

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

it('throw if release is not found', async () => {
await expect(
installer.installGraalPy('7.3.3', architecture, false, undefined)
).rejects.toThrow(
`GraalPy version 7.3.3 with arch ${architecture} not found`
);

expect(spyHttpClient).toHaveBeenCalled();
expect(spyDownloadTool).not.toHaveBeenCalled();
expect(spyExec).not.toHaveBeenCalled();
});

it('found and install GraalPy', async () => {
spyCacheDir = jest.spyOn(tc, 'cacheDir');
spyCacheDir.mockImplementation(() =>
path.join(toolDir, 'GraalPy', '21.3.0', architecture)
);

spyChmodSync = jest.spyOn(fs, 'chmodSync');
spyChmodSync.mockImplementation(() => undefined);

await expect(
installer.installGraalPy('21.x', architecture, false, undefined)
).resolves.toEqual({
installDir: path.join(toolDir, 'GraalPy', '21.3.0', architecture),
resolvedGraalPyVersion: '21.3.0'
});

expect(spyHttpClient).toHaveBeenCalled();
expect(spyDownloadTool).toHaveBeenCalled();
expect(spyCacheDir).toHaveBeenCalled();
expect(spyExec).toHaveBeenCalled();
});

it('found and install GraalPy, pre-release fallback', async () => {
spyCacheDir = jest.spyOn(tc, 'cacheDir');
spyCacheDir.mockImplementation(() =>
path.join(toolDir, 'GraalPy', '23.1.0', architecture)
);

spyChmodSync = jest.spyOn(fs, 'chmodSync');
spyChmodSync.mockImplementation(() => undefined);

await expect(
installer.installGraalPy('23.1.x', architecture, false, undefined)
).rejects.toThrow();
await expect(
installer.installGraalPy('23.1.x', architecture, true, undefined)
).resolves.toEqual({
installDir: path.join(toolDir, 'GraalPy', '23.1.0', architecture),
resolvedGraalPyVersion: '23.1.0-a.1'
});

expect(spyHttpClient).toHaveBeenCalled();
expect(spyDownloadTool).toHaveBeenCalled();
expect(spyCacheDir).toHaveBeenCalled();
expect(spyExec).toHaveBeenCalled();
});
});
4 changes: 2 additions & 2 deletions __tests__/install-pypy.test.ts
@@ -1,7 +1,7 @@
import fs from 'fs';

import {HttpClient} from '@actions/http-client';
import * as ifm from '@actions/http-client/interfaces';
import * as ifm from '@actions/http-client/lib/interfaces';
import * as tc from '@actions/tool-cache';
import * as exec from '@actions/exec';
import * as core from '@actions/core';
Expand Down Expand Up @@ -265,7 +265,7 @@ describe('installPyPy', () => {

spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
spyHttpClient.mockImplementation(
async (): Promise<ifm.ITypedResponse<IPyPyManifestRelease[]>> => {
async (): Promise<ifm.TypedResponse<IPyPyManifestRelease[]>> => {
const result = JSON.stringify(manifestData);
return {
statusCode: 200,
Expand Down
25 changes: 24 additions & 1 deletion __tests__/utils.test.ts
Expand Up @@ -11,7 +11,8 @@ import {
isCacheFeatureAvailable,
getVersionInputFromFile,
getVersionInputFromPlainFile,
getVersionInputFromTomlFile
getVersionInputFromTomlFile,
getNextPageUrl
} from '../src/utils';

jest.mock('@actions/cache');
Expand Down Expand Up @@ -136,3 +137,25 @@ describe('Version from file test', () => {
}
);
});

describe('getNextPageUrl', () => {
it('GitHub API pagination next page is parsed correctly', () => {
function generateResponse(link: string) {
return {
statusCode: 200,
result: null,
headers: {
link: link
}
};
}
const page1Links =
'<https://api.github.com/repositories/129883600/releases?page=2>; rel="next", <https://api.github.com/repositories/129883600/releases?page=3>; rel="last"';
expect(getNextPageUrl(generateResponse(page1Links))).toStrictEqual(
'https://api.github.com/repositories/129883600/releases?page=2'
);
const page2Links =
'<https://api.github.com/repositories/129883600/releases?page=1>; rel="prev", <https://api.github.com/repositories/129883600/releases?page=1>; rel="first"';
expect(getNextPageUrl(generateResponse(page2Links))).toBeNull();
});
});
2 changes: 1 addition & 1 deletion action.yml
Expand Up @@ -34,7 +34,7 @@ outputs:
python-path:
description: "The absolute path to the Python or PyPy executable."
runs:
using: 'node16'
using: 'node20'
main: 'dist/setup/index.js'
post: 'dist/cache-save/index.js'
post-if: success()
Expand Down
70,772 changes: 46,887 additions & 23,885 deletions dist/cache-save/index.js

Large diffs are not rendered by default.

64,312 changes: 43,662 additions & 20,650 deletions dist/setup/index.js

Large diffs are not rendered by default.

70 changes: 38 additions & 32 deletions docs/advanced-usage.md
Expand Up @@ -11,9 +11,10 @@
- [Outputs](advanced-usage.md#outputs)
- [Environment variables](advanced-usage.md#environment-variables)
- [Using update-environment flag](advanced-usage.md#using-update-environment-flag)
- [Available versions of Python and PyPy](advanced-usage.md#available-versions-of-python-and-pypy)
- [Available versions of Python and PyPy](advanced-usage.md#available-versions-of-python-pypy-and-graalpy)
- [Python](advanced-usage.md#python)
- [PyPy](advanced-usage.md#pypy)
- [GraalPy](advanced-usage.md#graalpy)
- [Hosted tool cache](advanced-usage.md#hosted-tool-cache)
- [Using `setup-python` with a self-hosted runner](advanced-usage.md#using-setup-python-with-a-self-hosted-runner)
- [Windows](advanced-usage.md#windows)
Expand All @@ -30,7 +31,7 @@ If there is a specific version of Python that you need and you don't want to wor

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.7.5'
Expand All @@ -44,7 +45,7 @@ You can specify **only a major and minor version** if you are okay with the most

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.7'
Expand All @@ -58,7 +59,7 @@ You can specify the version with **prerelease tag** to download and set up an ac

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.12.0-alpha.1'
Expand All @@ -69,7 +70,7 @@ It's also possible to use **x.y-dev syntax** to download and set up the latest p

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.12-dev'
Expand All @@ -82,7 +83,7 @@ You can also use several types of ranges that are specified in [semver](https://

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '>=3.9 <3.10'
Expand All @@ -93,7 +94,7 @@ steps:

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.12.0-alpha - 3.12.0'
Expand All @@ -104,7 +105,7 @@ steps:

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.x'
Expand All @@ -117,7 +118,7 @@ The version of PyPy should be specified in the format `pypy<python_version>[-v<p
The `-v<pypy_version>` parameter is optional and can be skipped. The latest PyPy version will be used in this case.

```
pypy3.8 or pypy-3.8 # the latest available version of PyPy that supports Python 3.8
pypy3.9 or pypy-3.9 # the latest available version of PyPy that supports Python 3.9
pypy2.7 or pypy-2.7 # the latest available version of PyPy that supports Python 2.7
pypy3.7-v7.3.3 or pypy-3.7-v7.3.3 # Python 3.7 and PyPy 7.3.3
pypy3.7-v7.x or pypy-3.7-v7.x # Python 3.7 and the latest available PyPy 7.x
Expand All @@ -137,7 +138,7 @@ jobs:
- 'pypy3.7' # the latest available version of PyPy that supports Python 3.7
- 'pypy3.7-v7.3.3' # Python 3.7 and PyPy 7.3.3
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
Expand All @@ -155,7 +156,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: |
Expand All @@ -172,7 +173,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: |
Expand All @@ -189,7 +190,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: |
Expand All @@ -211,10 +212,10 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ '2.x', '3.x', 'pypy2.7', 'pypy3.7', 'pypy3.8' ]
python-version: [ '2.x', '3.x', 'pypy2.7', 'pypy3.8', 'pypy3.9' ]
name: Python ${{ matrix.python-version }} sample
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
Expand All @@ -232,14 +233,14 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['2.7', '3.7', '3.8', '3.9', '3.10', 'pypy2.7', 'pypy3.8']
python-version: ['2.7', '3.7', '3.8', '3.9', '3.10', 'pypy2.7', 'pypy3.9']
exclude:
- os: macos-latest
python-version: '3.8'
- os: windows-latest
python-version: '3.6'
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
Expand All @@ -256,7 +257,7 @@ jobs:
```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version-file: '.python-version' # Read python version from a file .python-version
Expand All @@ -265,7 +266,7 @@ steps:

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version-file: 'pyproject.toml' # Read python version from a file pyproject.toml
Expand All @@ -280,7 +281,7 @@ If `check-latest` is set to `true`, the action first checks if the cached versio

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.7'
Expand All @@ -295,7 +296,7 @@ steps:
**Caching pipenv dependencies:**
```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.9'
Expand All @@ -308,7 +309,7 @@ steps:
**Caching poetry dependencies:**
```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v4
Expand All @@ -322,7 +323,7 @@ steps:
**Using a list of file paths to cache dependencies**
```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.9'
Expand All @@ -337,7 +338,7 @@ steps:
**Using wildcard patterns to cache dependencies**
```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.9'
Expand All @@ -349,7 +350,7 @@ steps:
**Using a list of wildcard patterns to cache dependencies**
```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.10'
Expand All @@ -364,7 +365,7 @@ steps:

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.11'
Expand All @@ -387,7 +388,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
id: cp310
with:
Expand All @@ -404,7 +405,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
id: cp310
with:
Expand All @@ -420,7 +421,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
id: cp310
with:
Expand Down Expand Up @@ -451,15 +452,15 @@ Such a requirement on side-effect could be because you don't want your composite

```yaml
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
id: cp310
with:
python-version: '3.10'
update-environment: false
- run: ${{ steps.cp310.outputs.python-path }} my_script.py
```
## Available versions of Python and PyPy
## Available versions of Python, PyPy and GraalPy
### Python

`setup-python` is able to configure **Python** from two sources:
Expand Down Expand Up @@ -490,6 +491,11 @@ Such a requirement on side-effect could be because you don't want your composite
- PyPy < 7.3.3 are not available to install on-flight.
- If some versions are not available, you can open an issue in https://foss.heptapod.net/pypy/pypy/

### GraalPy

`setup-python` is able to download GraalPy versions from the [official GraalPy repository](https://github.com/oracle/graalpython).
- All available versions that we can download are listed in [releases](https://github.com/oracle/graalpython/releases).

## Hosted tool cache

GitHub hosted runners have a tool cache that comes with a few versions of Python + PyPy already installed. This tool cache helps speed up runs and tool setup by not requiring any new downloads. There is an environment variable called `RUNNER_TOOL_CACHE` on each runner that describes the location of the tool cache with Python and PyPy installed. `setup-python` works by taking a specific version of Python or PyPy from this tool cache and adding it to PATH.
Expand Down Expand Up @@ -611,7 +617,7 @@ jobs:
python_version: ["3.11", "3.12"]

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "${{ matrix.python_version }}"
Expand Down
13,685 changes: 3,524 additions & 10,161 deletions package-lock.json

Large diffs are not rendered by default.

31 changes: 12 additions & 19 deletions package.json
@@ -1,6 +1,6 @@
{
"name": "setup-python",
"version": "4.0.0",
"version": "5.0.0",
"private": true,
"description": "Setup python action",
"main": "dist/index.js",
Expand All @@ -11,7 +11,7 @@
"lint": "eslint --config ./.eslintrc.js \"**/*.ts\"",
"lint:fix": "eslint --config ./.eslintrc.js \"**/*.ts\" --fix",
"release": "ncc build -o dist/setup src/setup-python.ts && ncc build -o dist/cache-save src/cache-save.ts && git add -f dist/",
"test": "jest --coverage"
"test": "jest --runInBand --coverage"
},
"repository": {
"type": "git",
Expand All @@ -28,35 +28,28 @@
"@actions/cache": "^3.0.4",
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.0",
"@actions/glob": "^0.2.0",
"@actions/http-client": "^1.0.11",
"@actions/glob": "^0.4.0",
"@actions/http-client": "^2.2.0",
"@actions/io": "^1.0.2",
"@actions/tool-cache": "^1.5.5",
"@actions/tool-cache": "^2.0.1",
"@iarna/toml": "^2.2.5",
"semver": "^7.5.2"
},
"devDependencies": {
"@types/jest": "^27.0.2",
"@types/node": "^16.11.25",
"@types/jest": "^29.5.6",
"@types/node": "^20.9.1",
"@types/semver": "^7.1.0",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"@vercel/ncc": "^0.33.4",
"@vercel/ncc": "^0.38.0",
"eslint": "^8.35.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-jest": "^27.2.1",
"eslint-plugin-node": "^11.1.0",
"husky": "^7.0.2",
"jest": "^27.2.5",
"jest-circus": "^27.2.5",
"jest": "^29.7.0",
"jest-circus": "^29.7.0",
"prettier": "^2.8.4",
"ts-jest": "^27.0.5",
"typescript": "^4.2.3"
},
"husky": {
"skipCI": true,
"hooks": {
"pre-commit": "npm run build && npm run format-check"
}
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
}
}
11 changes: 9 additions & 2 deletions src/cache-save.ts
Expand Up @@ -4,11 +4,18 @@ import * as cache from '@actions/cache';
import fs from 'fs';
import {State} from './cache-distributions/cache-distributor';

export async function run() {
// Added early exit to resolve issue with slow post action step:
// - https://github.com/actions/setup-node/issues/878
// https://github.com/actions/cache/pull/1217
export async function run(earlyExit?: boolean) {
try {
const cache = core.getInput('cache');
if (cache) {
await saveCache(cache);

if (earlyExit) {
process.exit(0);
}
}
} catch (error) {
const err = error as Error;
Expand Down Expand Up @@ -76,4 +83,4 @@ function isCacheDirectoryExists(cacheDirectory: string[]) {
return result;
}

run();
run(true);
146 changes: 146 additions & 0 deletions src/find-graalpy.ts
@@ -0,0 +1,146 @@
import * as path from 'path';
import * as graalpyInstall from './install-graalpy';
import {
IS_WINDOWS,
validateVersion,
IGraalPyManifestRelease,
getBinaryDirectory
} from './utils';

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

export async function findGraalPyVersion(
versionSpec: string,
architecture: string,
updateEnvironment: boolean,
checkLatest: boolean,
allowPreReleases: boolean
): Promise<string> {
let resolvedGraalPyVersion = '';
let installDir: string | null;
let releases: IGraalPyManifestRelease[] | undefined;

let graalpyVersionSpec = parseGraalPyVersion(versionSpec);

if (checkLatest) {
releases = await graalpyInstall.getAvailableGraalPyVersions();
if (releases && releases.length > 0) {
const releaseData = graalpyInstall.findRelease(
releases,
graalpyVersionSpec,
architecture,
false
);

if (releaseData) {
core.info(`Resolved as GraalPy ${releaseData.resolvedGraalPyVersion}`);
graalpyVersionSpec = releaseData.resolvedGraalPyVersion;
} else {
core.info(
`Failed to resolve GraalPy ${graalpyVersionSpec} from manifest`
);
}
}
}

({installDir, resolvedGraalPyVersion} = findGraalPyToolCache(
graalpyVersionSpec,
architecture
));

if (!installDir) {
({installDir, resolvedGraalPyVersion} = await graalpyInstall.installGraalPy(
graalpyVersionSpec,
architecture,
allowPreReleases,
releases
));
}

const pipDir = IS_WINDOWS ? 'Scripts' : 'bin';
const _binDir = path.join(installDir, pipDir);
const binaryExtension = IS_WINDOWS ? '.exe' : '';
const pythonPath = path.join(
IS_WINDOWS ? installDir : _binDir,
`python${binaryExtension}`
);
const pythonLocation = getBinaryDirectory(installDir);
if (updateEnvironment) {
core.exportVariable('pythonLocation', installDir);
// https://cmake.org/cmake/help/latest/module/FindPython.html#module:FindPython
core.exportVariable('Python_ROOT_DIR', installDir);
// https://cmake.org/cmake/help/latest/module/FindPython2.html#module:FindPython2
core.exportVariable('Python2_ROOT_DIR', installDir);
// https://cmake.org/cmake/help/latest/module/FindPython3.html#module:FindPython3
core.exportVariable('Python3_ROOT_DIR', installDir);
core.exportVariable('PKG_CONFIG_PATH', pythonLocation + '/lib/pkgconfig');
core.addPath(pythonLocation);
core.addPath(_binDir);
}
core.setOutput('python-version', 'graalpy' + resolvedGraalPyVersion);
core.setOutput('python-path', pythonPath);

return resolvedGraalPyVersion;
}

export function findGraalPyToolCache(
graalpyVersion: string,
architecture: string
) {
let resolvedGraalPyVersion = '';
let installDir: string | null = tc.find(
'GraalPy',
graalpyVersion,
architecture
);

if (installDir) {
// 'tc.find' finds tool based on Python version but we also need to check
// whether GraalPy version satisfies requested version.
resolvedGraalPyVersion = path.basename(path.dirname(installDir));

const isGraalPyVersionSatisfies = semver.satisfies(
resolvedGraalPyVersion,
graalpyVersion
);
if (!isGraalPyVersionSatisfies) {
installDir = null;
resolvedGraalPyVersion = '';
}
}

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

return {installDir, resolvedGraalPyVersion};
}

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

if (/^(graalpy)(.+)/.test(versions[0])) {
const version = versions[0].replace('graalpy', '');
versions.splice(0, 1, 'graalpy', version);
}

if (versions.length < 2 || versions[0] != 'graalpy') {
throw new Error(
"Invalid 'version' property for GraalPy. GraalPy version should be specified as 'graalpy<python-version>' or 'graalpy-<python-version>'. See README for examples and documentation."
);
}

const pythonVersion = versions[1];

if (!validateVersion(pythonVersion)) {
throw new Error(
"Invalid 'version' property for GraalPy. GraalPy versions should satisfy SemVer notation. See README for examples and documentation."
);
}

return pythonVersion;
}
5 changes: 3 additions & 2 deletions src/find-pypy.ts
Expand Up @@ -7,7 +7,8 @@ import {
getPyPyVersionFromPath,
readExactPyPyVersionFile,
validatePythonVersionFormatForPyPy,
IPyPyManifestRelease
IPyPyManifestRelease,
getBinaryDirectory
} from './utils';

import * as semver from 'semver';
Expand Down Expand Up @@ -82,7 +83,7 @@ export async function findPyPyVersion(
IS_WINDOWS ? installDir : _binDir,
`python${binaryExtension}`
);
const pythonLocation = pypyInstall.getPyPyBinaryPath(installDir);
const pythonLocation = getBinaryDirectory(installDir);
if (updateEnvironment) {
core.exportVariable('pythonLocation', installDir);
// https://cmake.org/cmake/help/latest/module/FindPython.html#module:FindPython
Expand Down
264 changes: 264 additions & 0 deletions src/install-graalpy.ts
@@ -0,0 +1,264 @@
import * as os from 'os';
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 ifm from '@actions/http-client/lib/interfaces';
import * as exec from '@actions/exec';

import fs from 'fs';
import * as http from 'http';

import {
IS_WINDOWS,
IGraalPyManifestRelease,
createSymlinkInFolder,
isNightlyKeyword,
getBinaryDirectory,
getNextPageUrl
} from './utils';

const TOKEN = core.getInput('token');
const AUTH = !TOKEN ? undefined : `token ${TOKEN}`;

export async function installGraalPy(
graalpyVersion: string,
architecture: string,
allowPreReleases: boolean,
releases: IGraalPyManifestRelease[] | undefined
) {
let downloadDir;

releases = releases ?? (await getAvailableGraalPyVersions());

if (!releases || !releases.length) {
throw new Error('No release was found in GraalPy version.json');
}

let releaseData = findRelease(releases, graalpyVersion, architecture, false);

if (allowPreReleases && (!releaseData || !releaseData.foundAsset)) {
// check for pre-release
core.info(
[
`Stable GraalPy version ${graalpyVersion} with arch ${architecture} not found`,
`Trying pre-release versions`
].join(os.EOL)
);
releaseData = findRelease(releases, graalpyVersion, architecture, true);
}

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

const {foundAsset, resolvedGraalPyVersion} = releaseData;
const downloadUrl = `${foundAsset.browser_download_url}`;

core.info(`Downloading GraalPy from "${downloadUrl}" ...`);

try {
const graalpyPath = await tc.downloadTool(downloadUrl, undefined, AUTH);

core.info('Extracting downloaded archive...');
downloadDir = await tc.extractTar(graalpyPath);

// 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(resolvedGraalPyVersion)) {
installDir = await tc.cacheDir(
toolDir,
'GraalPy',
resolvedGraalPyVersion,
architecture
);
}

const binaryPath = getBinaryDirectory(installDir);
await createGraalPySymlink(binaryPath, resolvedGraalPyVersion);
await installPip(binaryPath);

return {installDir, resolvedGraalPyVersion};
} catch (err) {
if (err instanceof Error) {
// Rate limit?
if (
err instanceof tc.HTTPError &&
(err.httpStatusCode === 403 || err.httpStatusCode === 429)
) {
core.info(
`Received HTTP status code ${err.httpStatusCode}. This usually indicates the rate limit has been exceeded`
);
} else {
core.info(err.message);
}
if (err.stack !== undefined) {
core.debug(err.stack);
}
}
throw err;
}
}

export async function getAvailableGraalPyVersions() {
const http: httpm.HttpClient = new httpm.HttpClient('tool-cache');

const headers: http.OutgoingHttpHeaders = {};
if (AUTH) {
headers.authorization = AUTH;
}

let url: string | null =
'https://api.github.com/repos/oracle/graalpython/releases';
const result: IGraalPyManifestRelease[] = [];
do {
const response: ifm.TypedResponse<IGraalPyManifestRelease[]> =
await http.getJson(url, headers);
if (!response.result) {
throw new Error(
`Unable to retrieve the list of available GraalPy versions from '${url}'`
);
}
result.push(...response.result);
url = getNextPageUrl(response);
} while (url);

return result;
}

async function createGraalPySymlink(
graalpyBinaryPath: string,
graalpyVersion: string
) {
const version = semver.coerce(graalpyVersion)!;
const pythonBinaryPostfix = semver.major(version);
const pythonMinor = semver.minor(version);
const graalpyMajorMinorBinaryPostfix = `${pythonBinaryPostfix}.${pythonMinor}`;
const binaryExtension = IS_WINDOWS ? '.exe' : '';

core.info('Creating symlinks...');
createSymlinkInFolder(
graalpyBinaryPath,
`graalpy${binaryExtension}`,
`python${pythonBinaryPostfix}${binaryExtension}`,
true
);

createSymlinkInFolder(
graalpyBinaryPath,
`graalpy${binaryExtension}`,
`python${binaryExtension}`,
true
);

createSymlinkInFolder(
graalpyBinaryPath,
`graalpy${binaryExtension}`,
`graalpy${graalpyMajorMinorBinaryPostfix}${binaryExtension}`,
true
);
}

async function installPip(pythonLocation: string) {
core.info(
"Installing pip (GraalPy doesn't update pip because it uses a patched version of pip)"
);
const pythonBinary = path.join(pythonLocation, 'python');
await exec.exec(`${pythonBinary} -m ensurepip --default-pip`);
}

export function graalPyTagToVersion(tag: string) {
const versionPattern = /.*-(\d+\.\d+\.\d+(?:\.\d+)?)((?:a|b|rc))?(\d*)?/;
const match = tag.match(versionPattern);
if (match && match[2]) {
return `${match[1]}-${match[2]}.${match[3]}`;
} else if (match) {
return match[1];
} else {
return tag.replace(/.*-/, '');
}
}

export function findRelease(
releases: IGraalPyManifestRelease[],
graalpyVersion: string,
architecture: string,
includePrerelease: boolean
) {
const options = {includePrerelease: includePrerelease};
const filterReleases = releases.filter(item => {
const isVersionSatisfied = semver.satisfies(
graalPyTagToVersion(item.tag_name),
graalpyVersion,
options
);
return (
isVersionSatisfied && !!findAsset(item, architecture, process.platform)
);
});

if (!filterReleases.length) {
return null;
}

const sortedReleases = filterReleases.sort((previous, current) =>
semver.compare(
semver.coerce(graalPyTagToVersion(current.tag_name))!,
semver.coerce(graalPyTagToVersion(previous.tag_name))!
)
);

const foundRelease = sortedReleases[0];
const foundAsset = findAsset(foundRelease, architecture, process.platform);

return {
foundAsset,
resolvedGraalPyVersion: graalPyTagToVersion(foundRelease.tag_name)
};
}

export function toGraalPyPlatform(platform: string) {
switch (platform) {
case 'win32':
return 'windows';
case 'darwin':
return 'macos';
}
return platform;
}

export function toGraalPyArchitecture(architecture: string) {
switch (architecture) {
case 'x64':
return 'amd64';
case 'arm64':
return 'aarch64';
}
return architecture;
}

export function findAsset(
item: IGraalPyManifestRelease,
architecture: string,
platform: string
) {
const graalpyArch = toGraalPyArchitecture(architecture);
const graalpyPlatform = toGraalPyPlatform(platform);
const found = item.assets.filter(
file =>
file.name.startsWith('graalpy') &&
file.name.endsWith(`-${graalpyPlatform}-${graalpyArch}.tar.gz`)
);
/*
In the future there could be more variants of GraalPy for a single release. Pick the shortest name, that one is the most likely to be the primary variant.
*/
found.sort((f1, f2) => f1.name.length - f2.name.length);
return found[0];
}
14 changes: 3 additions & 11 deletions src/install-pypy.ts
Expand Up @@ -13,7 +13,8 @@ import {
IPyPyManifestRelease,
createSymlinkInFolder,
isNightlyKeyword,
writeExactPyPyVersionFile
writeExactPyPyVersionFile,
getBinaryDirectory
} from './utils';

export async function installPyPy(
Expand Down Expand Up @@ -94,7 +95,7 @@ export async function installPyPy(

writeExactPyPyVersionFile(installDir, resolvedPyPyVersion);

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

Expand Down Expand Up @@ -237,15 +238,6 @@ export function findRelease(
};
}

/** 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;
}

export function pypyVersionToSemantic(versionSpec: string) {
const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc))(\d*)/g;
return versionSpec.replace(prereleaseVersion, '$1-$2.$3');
Expand Down
17 changes: 16 additions & 1 deletion src/setup-python.ts
@@ -1,6 +1,7 @@
import * as core from '@actions/core';
import * as finder from './find-python';
import * as finderPyPy from './find-pypy';
import * as finderGraalPy from './find-graalpy';
import * as path from 'path';
import * as os from 'os';
import fs from 'fs';
Expand All @@ -17,6 +18,10 @@ function isPyPyVersion(versionSpec: string) {
return versionSpec.startsWith('pypy');
}

function isGraalPyVersion(versionSpec: string) {
return versionSpec.startsWith('graalpy');
}

async function cacheDependencies(cache: string, pythonVersion: string) {
const cacheDependencyPath =
core.getInput('cache-dependency-path') || undefined;
Expand Down Expand Up @@ -106,10 +111,20 @@ async function run() {
core.info(
`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`
);
} else if (isGraalPyVersion(version)) {
const installed = await finderGraalPy.findGraalPyVersion(
version,
arch,
updateEnvironment,
checkLatest,
allowPreReleases
);
pythonVersion = `${installed}`;
core.info(`Successfully set up GraalPy ${installed}`);
} else {
if (version.startsWith('2')) {
core.warning(
'The support for python 2.7 will be removed on June 19. Related issue: https://github.com/actions/setup-python/issues/672'
'The support for python 2.7 was removed on June 19, 2023. Related issue: https://github.com/actions/setup-python/issues/672'
);
}
const installed = await finder.useCpythonVersion(
Expand Down
48 changes: 46 additions & 2 deletions src/utils.ts
Expand Up @@ -6,6 +6,9 @@ import * as path from 'path';
import * as semver from 'semver';
import * as toml from '@iarna/toml';
import * as exec from '@actions/exec';
import * as ifm from '@actions/http-client/lib/interfaces';

import * as http from 'http';

export const IS_WINDOWS = process.platform === 'win32';
export const IS_LINUX = process.platform === 'linux';
Expand All @@ -29,6 +32,16 @@ export interface IPyPyManifestRelease {
files: IPyPyManifestAsset[];
}

export interface IGraalPyManifestAsset {
name: string;
browser_download_url: string;
}

export interface IGraalPyManifestRelease {
tag_name: string;
assets: IGraalPyManifestAsset[];
}

/** create Symlinks for downloaded PyPy
* It should be executed only for downloaded versions in runtime, because
* toolcache versions have this setup.
Expand Down Expand Up @@ -90,7 +103,7 @@ export function writeExactPyPyVersionFile(
}

/**
* Python version should be specified explicitly like "x.y" (2.7, 3.6, 3.7)
* Python version should be specified explicitly like "x.y" (3.10, 3.11, etc)
* "3.x" or "3" are not supported
* because it could cause ambiguity when both PyPy version and Python version are not precise
*/
Expand Down Expand Up @@ -251,7 +264,7 @@ export function getVersionInputFromTomlFile(versionFile: string): string[] {
*/
export function getVersionInputFromPlainFile(versionFile: string): string[] {
core.debug(`Trying to resolve version form ${versionFile}`);
const version = fs.readFileSync(versionFile, 'utf8');
const version = fs.readFileSync(versionFile, 'utf8').trim();
core.info(`Resolved ${versionFile} as ${version}`);
return [version];
}
Expand All @@ -266,3 +279,34 @@ export function getVersionInputFromFile(versionFile: string): string[] {
return getVersionInputFromPlainFile(versionFile);
}
}

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

/**
* Extract next page URL from a HTTP response "link" header. Such headers are used in GitHub APIs.
*/
export function getNextPageUrl<T>(response: ifm.TypedResponse<T>) {
const responseHeaders = <http.OutgoingHttpHeaders>response.headers;
const linkHeader = responseHeaders.link;
if (typeof linkHeader === 'string') {
for (const link of linkHeader.split(/\s*,\s*/)) {
const match = link.match(/<([^>]+)>(.*)/);
if (match) {
const url = match[1];
for (const param of match[2].split(/\s*;\s*/)) {
if (param.match(/rel="?next"?/)) {
return url;
}
}
}
}
}
return null;
}