diff --git a/__tests__/find-python.test.ts b/__tests__/find-python.test.ts index e779a7a2..6178ceec 100644 --- a/__tests__/find-python.test.ts +++ b/__tests__/find-python.test.ts @@ -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); + }); +}); diff --git a/__tests__/finder.test.ts b/__tests__/finder.test.ts index b1c3f9f1..285a071c 100644 --- a/__tests__/finder.test.ts +++ b/__tests__/finder.test.ts @@ -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; } diff --git a/action.yml b/action.yml index 48755e9d..efa8de90 100644 --- a/action.yml +++ b/action.yml @@ -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." diff --git a/dist/setup/index.js b/dist/setup/index.js index a0bed3bf..d3bf3ead 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -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})`); } diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index dc8665d3..d6c092c7 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -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: diff --git a/src/find-python.ts b/src/find-python.ts index 6685d060..606c4802 100644 --- a/src/find-python.ts +++ b/src/find-python.ts @@ -35,20 +35,26 @@ export async function useCpythonVersion( architecture: string, updateEnvironment: boolean, checkLatest: boolean, - allowPreReleases: boolean + allowPreReleases: boolean, + freethreaded: boolean ): Promise { 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'); } diff --git a/src/setup-python.ts b/src/setup-python.ts index 0dd45f0c..ab5931b8 100644 --- a/src/setup-python.ts +++ b/src/setup-python.ts @@ -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})`);