Add freethreaded input and fix handling of prerelease versions

This commit is contained in:
Sam Gross 2025-01-30 19:46:04 +00:00
parent d653c0b66e
commit 72902a03bd
7 changed files with 163 additions and 55 deletions

View File

@ -1,18 +1,54 @@
import {desugarVersion} from '../src/find-python';
import {desugarVersion, pythonVersionToSemantic} from '../src/find-python';
describe('desugarVersion', () => {
it.each([
['3.13', ['3.13', '']],
['3.13t', ['3.13', '-freethreaded']],
['3.13.1', ['3.13.1', '']],
['3.13.1t', ['3.13.1', '-freethreaded']],
['3.14-dev', ['~3.14.0-0', '']],
['3.14t-dev', ['~3.14.0-0', '-freethreaded']],
['3.14.0a4', ['3.14.0a4', '']],
['3.14.0ta4', ['3.14.0a4', '-freethreaded']],
['3.14.0rc1', ['3.14.0rc1', '']],
['3.14.0trc1', ['3.14.0rc1', '-freethreaded']]
['3.13', {version: '3.13', freethreaded: false}],
['3.13t', {version: '3.13', freethreaded: true}],
['3.13.1', {version: '3.13.1', freethreaded: false}],
['3.13.1t', {version: '3.13.1', freethreaded: true}],
['3.14-dev', {version: '~3.14.0-0', freethreaded: false}],
['3.14t-dev', {version: '~3.14.0-0', freethreaded: true}],
['3.14.0a4', {version: '3.14.0a4', freethreaded: false}],
['3.14.0rc1', {version: '3.14.0rc1', freethreaded: false}],
['3.14.0rc1t', {version: '3.14.0rc1', freethreaded: true}]
])('%s -> %s', (input, expected) => {
expect(desugarVersion(input)).toEqual(expected);
});
});
// Test the combined desugarVersion and pythonVersionToSemantic functions
describe('pythonVersions', () => {
it.each([
['3.13', {version: '3.13', freethreaded: false}],
['3.13t', {version: '3.13', freethreaded: true}],
['3.13.1', {version: '3.13.1', freethreaded: false}],
['3.13.1t', {version: '3.13.1', freethreaded: true}],
['3.14-dev', {version: '~3.14.0-0', freethreaded: false}],
['3.14t-dev', {version: '~3.14.0-0', freethreaded: true}],
['3.14.0a4', {version: '3.14.0-alpha.4', freethreaded: false}],
['3.14.0a4t', {version: '3.14.0-alpha.4', freethreaded: true}],
['3.14.0rc1', {version: '3.14.0-rc.1', freethreaded: false}],
['3.14.0rc1t', {version: '3.14.0-rc.1', freethreaded: true}]
])('%s -> %s', (input, expected) => {
const {version, freethreaded} = desugarVersion(input);
let semanticVersionSpec = pythonVersionToSemantic(version, false);
expect({version: semanticVersionSpec, freethreaded}).toEqual(expected);
});
it.each([
['3.13', {version: '~3.13.0-0', freethreaded: false}],
['3.13t', {version: '~3.13.0-0', freethreaded: true}],
['3.13.1', {version: '3.13.1', freethreaded: false}],
['3.13.1t', {version: '3.13.1', freethreaded: true}],
['3.14-dev', {version: '~3.14.0-0', freethreaded: false}],
['3.14t-dev', {version: '~3.14.0-0', freethreaded: true}],
['3.14.0a4', {version: '3.14.0-alpha.4', freethreaded: false}],
['3.14.0a4t', {version: '3.14.0-alpha.4', freethreaded: true}],
['3.14.0rc1', {version: '3.14.0-rc.1', freethreaded: false}],
['3.14.0rc1t', {version: '3.14.0-rc.1', freethreaded: true}]
])('%s (allowPreReleases=true) -> %s', (input, expected) => {
const {version, freethreaded} = desugarVersion(input);
let semanticVersionSpec = pythonVersionToSemantic(version, true);
expect({version: semanticVersionSpec, freethreaded}).toEqual(expected);
});
});

View File

@ -56,7 +56,7 @@ describe('Finder tests', () => {
await io.mkdirP(pythonDir);
fs.writeFileSync(`${pythonDir}.complete`, 'hello');
// This will throw if it doesn't find it in the cache and in the manifest (because no such version exists)
await finder.useCpythonVersion('3.x', 'x64', true, false, false);
await finder.useCpythonVersion('3.x', 'x64', true, false, false, false);
expect(spyCoreAddPath).toHaveBeenCalled();
expect(spyCoreExportVariable).toHaveBeenCalledWith(
'pythonLocation',
@ -73,7 +73,7 @@ describe('Finder tests', () => {
await io.mkdirP(pythonDir);
fs.writeFileSync(`${pythonDir}.complete`, 'hello');
// This will throw if it doesn't find it in the cache and in the manifest (because no such version exists)
await finder.useCpythonVersion('3.x', 'x64', false, false, false);
await finder.useCpythonVersion('3.x', 'x64', false, false, false, false);
expect(spyCoreAddPath).not.toHaveBeenCalled();
expect(spyCoreExportVariable).not.toHaveBeenCalled();
});
@ -96,7 +96,7 @@ describe('Finder tests', () => {
});
// This will throw if it doesn't find it in the cache and in the manifest (because no such version exists)
await expect(
finder.useCpythonVersion('1.2.3', 'x64', true, false, false)
finder.useCpythonVersion('1.2.3', 'x64', true, false, false, false)
).resolves.toEqual({
impl: 'CPython',
version: '1.2.3'
@ -135,7 +135,14 @@ describe('Finder tests', () => {
});
// This will throw if it doesn't find it in the manifest (because no such version exists)
await expect(
finder.useCpythonVersion('1.2.4-beta.2', 'x64', false, false, false)
finder.useCpythonVersion(
'1.2.4-beta.2',
'x64',
false,
false,
false,
false
)
).resolves.toEqual({
impl: 'CPython',
version: '1.2.4-beta.2'
@ -186,7 +193,7 @@ describe('Finder tests', () => {
fs.writeFileSync(`${pythonDir}.complete`, 'hello');
// This will throw if it doesn't find it in the cache and in the manifest (because no such version exists)
await finder.useCpythonVersion('1.2', 'x64', true, true, false);
await finder.useCpythonVersion('1.2', 'x64', true, true, false, false);
expect(infoSpy).toHaveBeenCalledWith("Resolved as '1.2.3'");
expect(infoSpy).toHaveBeenCalledWith(
@ -197,7 +204,14 @@ describe('Finder tests', () => {
);
expect(installSpy).toHaveBeenCalled();
expect(addPathSpy).toHaveBeenCalledWith(expPath);
await finder.useCpythonVersion('1.2.4-beta.2', 'x64', false, true, false);
await finder.useCpythonVersion(
'1.2.4-beta.2',
'x64',
false,
true,
false,
false
);
expect(spyCoreAddPath).toHaveBeenCalled();
expect(spyCoreExportVariable).toHaveBeenCalledWith(
'pythonLocation',
@ -224,7 +238,7 @@ describe('Finder tests', () => {
});
// This will throw if it doesn't find it in the cache and in the manifest (because no such version exists)
await expect(
finder.useCpythonVersion('1.2', 'x64', false, false, false)
finder.useCpythonVersion('1.2', 'x64', false, false, false, false)
).resolves.toEqual({
impl: 'CPython',
version: '1.2.3'
@ -251,17 +265,17 @@ describe('Finder tests', () => {
});
// This will throw if it doesn't find it in the cache and in the manifest (because no such version exists)
await expect(
finder.useCpythonVersion('1.1', 'x64', false, false, false)
finder.useCpythonVersion('1.1', 'x64', false, false, false, false)
).rejects.toThrow();
await expect(
finder.useCpythonVersion('1.1', 'x64', false, false, true)
finder.useCpythonVersion('1.1', 'x64', false, false, true, false)
).resolves.toEqual({
impl: 'CPython',
version: '1.1.0-beta.2'
});
// Check 1.1.0 version specifier does not fallback to '1.1.0-beta.2'
await expect(
finder.useCpythonVersion('1.1.0', 'x64', false, false, true)
finder.useCpythonVersion('1.1.0', 'x64', false, false, true, false)
).rejects.toThrow();
});
@ -269,7 +283,14 @@ describe('Finder tests', () => {
// This will throw if it doesn't find it in the cache and in the manifest (because no such version exists)
let thrown = false;
try {
await finder.useCpythonVersion('3.300000', 'x64', true, false, false);
await finder.useCpythonVersion(
'3.300000',
'x64',
true,
false,
false,
false
);
} catch {
thrown = true;
}

View File

@ -26,6 +26,9 @@ inputs:
allow-prereleases:
description: "When 'true', a version range passed to 'python-version' input will match prerelease versions if no GA versions are found. Only 'x.y' version range is supported for CPython."
default: false
freethreaded:
description: "When 'true', use the freethreaded version of Python."
default: false
outputs:
python-version:
description: "The installed Python or PyPy version. Useful when given a version range as input."

48
dist/setup/index.js vendored
View File

@ -91034,17 +91034,21 @@ function binDir(installDir) {
return path.join(installDir, 'bin');
}
}
function useCpythonVersion(version, architecture, updateEnvironment, checkLatest, allowPreReleases) {
function useCpythonVersion(version, architecture, updateEnvironment, checkLatest, allowPreReleases, freethreaded) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
let manifest = null;
const [desugaredVersionSpec, freethreaded] = desugarVersion(version);
const { version: desugaredVersionSpec, freethreaded: versionFreethreaded } = desugarVersion(version);
let semanticVersionSpec = pythonVersionToSemantic(desugaredVersionSpec, allowPreReleases);
if (versionFreethreaded) {
// Use the freethreaded version if it was specified in the input, e.g., 3.13t
freethreaded = true;
}
core.debug(`Semantic version spec of ${version} is ${semanticVersionSpec}`);
if (freethreaded) {
// Free threaded versions use an architecture suffix like `x64-freethreaded`
core.debug(`Using freethreaded version of ${semanticVersionSpec}`);
architecture += freethreaded;
architecture += '-freethreaded';
}
if (checkLatest) {
manifest = yield installer.getManifest();
@ -91122,27 +91126,33 @@ function useCpythonVersion(version, architecture, updateEnvironment, checkLatest
exports.useCpythonVersion = useCpythonVersion;
/* Desugar free threaded and dev versions */
function desugarVersion(versionSpec) {
const [desugaredVersionSpec, freethreaded] = desugarFreeThreadedVersion(versionSpec);
const desugaredVersionSpec2 = desugarDevVersion(desugaredVersionSpec);
return [desugaredVersionSpec2, freethreaded];
const { version, freethreaded } = desugarFreeThreadedVersion(versionSpec);
return { version: desugarDevVersion(version), freethreaded };
}
exports.desugarVersion = desugarVersion;
/* Identify freethreaded versions like, 3.13t, 3.13.1t, 3.13t-dev, 3.14.0a1t.
* Returns the version without the `t` and the architectures suffix, if freethreaded */
function desugarFreeThreadedVersion(versionSpec) {
const prereleaseVersion = /(\d+\.\d+\.\d+)(t)((?:a|b|rc)\d*)/g;
// e.g., 3.14.0a1t -> 3.14.0a1
const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc)\d*)(t)/g;
if (prereleaseVersion.test(versionSpec)) {
return [versionSpec.replace(prereleaseVersion, '$1$3'), '-freethreaded'];
return {
version: versionSpec.replace(prereleaseVersion, '$1$2'),
freethreaded: true
};
}
const majorMinor = /^(\d+\.\d+(\.\d+)?)(t)$/;
if (majorMinor.test(versionSpec)) {
return [versionSpec.replace(majorMinor, '$1'), '-freethreaded'];
return { version: versionSpec.replace(majorMinor, '$1'), freethreaded: true };
}
const devVersion = /^(\d+\.\d+)(t)(-dev)$/;
if (devVersion.test(versionSpec)) {
return [versionSpec.replace(devVersion, '$1$3'), '-freethreaded'];
return {
version: versionSpec.replace(devVersion, '$1$3'),
freethreaded: true
};
}
return [versionSpec, ''];
return { version: versionSpec, freethreaded: false };
}
/** Convert versions like `3.8-dev` to a version like `~3.8.0-0`. */
function desugarDevVersion(versionSpec) {
@ -91157,15 +91167,22 @@ function versionFromPath(installDir) {
}
/**
* Python's prelease versions look like `3.7.0b2`.
* This is the one part of Python versioning that does not look like semantic versioning, which specifies `3.7.0-b2`.
* This is the one part of Python versioning that does not look like semantic versioning, which specifies `3.7.0-beta.2`.
* If the version spec contains prerelease versions, we need to convert them to the semantic version equivalent.
*
* For easier use of the action, we also map 'x.y' to allow pre-release before 'x.y.0' release if allowPreReleases is true
*/
function pythonVersionToSemantic(versionSpec, allowPreReleases) {
const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc)\d*)/g;
const preleaseMap = {
a: 'alpha',
b: 'beta',
rc: 'rc'
};
const prereleaseVersion = /(\d+\.\d+\.\d+)(a|b|rc)(\d+)/g;
let result = versionSpec.replace(prereleaseVersion, (_, p1, p2, p3) => {
return `${p1}-${preleaseMap[p2]}.${p3}`;
});
const majorMinor = /^(\d+)\.(\d+)$/;
let result = versionSpec.replace(prereleaseVersion, '$1-$2');
if (allowPreReleases) {
result = result.replace(majorMinor, '~$1.$2.0-0');
}
@ -91881,6 +91898,7 @@ function run() {
const versions = resolveVersionInput();
const checkLatest = core.getBooleanInput('check-latest');
const allowPreReleases = core.getBooleanInput('allow-prereleases');
const freethreaded = core.getBooleanInput('freethreaded');
if (versions.length) {
let pythonVersion = '';
const arch = core.getInput('architecture') || os.arch();
@ -91901,7 +91919,7 @@ function run() {
if (version.startsWith('2')) {
core.warning('The support for python 2.7 was removed on June 19, 2023. Related issue: https://github.com/actions/setup-python/issues/672');
}
const installed = yield finder.useCpythonVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases);
const installed = yield finder.useCpythonVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases, freethreaded);
pythonVersion = installed.version;
core.info(`Successfully set up ${installed.impl} (${pythonVersion})`);
}

View File

@ -77,7 +77,7 @@ steps:
- run: python my_script.py
```
Use the **t** suffix to select the [free threading](https://docs.python.org/3/howto/free-threading-python.html) version of Python.
You can specify the [free threading](https://docs.python.org/3/howto/free-threading-python.html) version of Python by setting the `freethreaded` input to `true` or by using the special **t** suffix in some cases. Pre-release free threading versions can be specified like `3.14.0a3t` or `3.14t-dev`.
Free threaded Python is only available starting with the 3.13 release.
```yaml
@ -89,7 +89,17 @@ steps:
- run: python my_script.py
```
Pre-release free threading versions should be specified like `3.14.0ta3` or `3.14t-dev`.
Note that the **t** suffix is not `semver` syntax. If you wish to specify a range, you must use the `freethreaded` input instead of the `t` suffix.
```yaml
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '>=3.13'
freethreaded: true
- run: python my_script.py
```
You can also use several types of ranges that are specified in [semver](https://github.com/npm/node-semver#ranges), for instance:

View File

@ -35,20 +35,26 @@ export async function useCpythonVersion(
architecture: string,
updateEnvironment: boolean,
checkLatest: boolean,
allowPreReleases: boolean
allowPreReleases: boolean,
freethreaded: boolean
): Promise<InstalledVersion> {
let manifest: tc.IToolRelease[] | null = null;
const [desugaredVersionSpec, freethreaded] = desugarVersion(version);
const {version: desugaredVersionSpec, freethreaded: versionFreethreaded} =
desugarVersion(version);
let semanticVersionSpec = pythonVersionToSemantic(
desugaredVersionSpec,
allowPreReleases
);
if (versionFreethreaded) {
// Use the freethreaded version if it was specified in the input, e.g., 3.13t
freethreaded = true;
}
core.debug(`Semantic version spec of ${version} is ${semanticVersionSpec}`);
if (freethreaded) {
// Free threaded versions use an architecture suffix like `x64-freethreaded`
core.debug(`Using freethreaded version of ${semanticVersionSpec}`);
architecture += freethreaded;
architecture += '-freethreaded';
}
if (checkLatest) {
@ -167,28 +173,33 @@ export async function useCpythonVersion(
/* Desugar free threaded and dev versions */
export function desugarVersion(versionSpec: string) {
const [desugaredVersionSpec, freethreaded] =
desugarFreeThreadedVersion(versionSpec);
const desugaredVersionSpec2 = desugarDevVersion(desugaredVersionSpec);
return [desugaredVersionSpec2, freethreaded];
const {version, freethreaded} = desugarFreeThreadedVersion(versionSpec);
return {version: desugarDevVersion(version), freethreaded};
}
/* Identify freethreaded versions like, 3.13t, 3.13.1t, 3.13t-dev, 3.14.0a1t.
* Returns the version without the `t` and the architectures suffix, if freethreaded */
function desugarFreeThreadedVersion(versionSpec: string) {
const prereleaseVersion = /(\d+\.\d+\.\d+)(t)((?:a|b|rc)\d*)/g;
// e.g., 3.14.0a1t -> 3.14.0a1
const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc)\d*)(t)/g;
if (prereleaseVersion.test(versionSpec)) {
return [versionSpec.replace(prereleaseVersion, '$1$3'), '-freethreaded'];
return {
version: versionSpec.replace(prereleaseVersion, '$1$2'),
freethreaded: true
};
}
const majorMinor = /^(\d+\.\d+(\.\d+)?)(t)$/;
if (majorMinor.test(versionSpec)) {
return [versionSpec.replace(majorMinor, '$1'), '-freethreaded'];
return {version: versionSpec.replace(majorMinor, '$1'), freethreaded: true};
}
const devVersion = /^(\d+\.\d+)(t)(-dev)$/;
if (devVersion.test(versionSpec)) {
return [versionSpec.replace(devVersion, '$1$3'), '-freethreaded'];
return {
version: versionSpec.replace(devVersion, '$1$3'),
freethreaded: true
};
}
return [versionSpec, ''];
return {version: versionSpec, freethreaded: false};
}
/** Convert versions like `3.8-dev` to a version like `~3.8.0-0`. */
@ -212,7 +223,7 @@ interface InstalledVersion {
/**
* Python's prelease versions look like `3.7.0b2`.
* This is the one part of Python versioning that does not look like semantic versioning, which specifies `3.7.0-b2`.
* This is the one part of Python versioning that does not look like semantic versioning, which specifies `3.7.0-beta.2`.
* If the version spec contains prerelease versions, we need to convert them to the semantic version equivalent.
*
* For easier use of the action, we also map 'x.y' to allow pre-release before 'x.y.0' release if allowPreReleases is true
@ -221,9 +232,16 @@ export function pythonVersionToSemantic(
versionSpec: string,
allowPreReleases: boolean
) {
const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc)\d*)/g;
const preleaseMap: {[key: string]: string} = {
a: 'alpha',
b: 'beta',
rc: 'rc'
};
const prereleaseVersion = /(\d+\.\d+\.\d+)(a|b|rc)(\d+)/g;
let result = versionSpec.replace(prereleaseVersion, (_, p1, p2, p3) => {
return `${p1}-${preleaseMap[p2]}.${p3}`;
});
const majorMinor = /^(\d+)\.(\d+)$/;
let result = versionSpec.replace(prereleaseVersion, '$1-$2');
if (allowPreReleases) {
result = result.replace(majorMinor, '~$1.$2.0-0');
}

View File

@ -92,6 +92,7 @@ async function run() {
const versions = resolveVersionInput();
const checkLatest = core.getBooleanInput('check-latest');
const allowPreReleases = core.getBooleanInput('allow-prereleases');
const freethreaded = core.getBooleanInput('freethreaded');
if (versions.length) {
let pythonVersion = '';
@ -132,7 +133,8 @@ async function run() {
arch,
updateEnvironment,
checkLatest,
allowPreReleases
allowPreReleases,
freethreaded
);
pythonVersion = installed.version;
core.info(`Successfully set up ${installed.impl} (${pythonVersion})`);