diff --git a/__tests__/finder.test.ts b/__tests__/finder.test.ts index 285a071c..f696b441 100644 --- a/__tests__/finder.test.ts +++ b/__tests__/finder.test.ts @@ -21,6 +21,7 @@ process.env['RUNNER_TEMP'] = tempDir; import * as tc from '@actions/tool-cache'; import * as core from '@actions/core'; +import * as exec from '@actions/exec'; import * as finder from '../src/find-python'; import * as installer from '../src/install-python'; @@ -298,4 +299,133 @@ describe('Finder tests', () => { expect(spyCoreAddPath).not.toHaveBeenCalled(); expect(spyCoreExportVariable).not.toHaveBeenCalled(); }); + + describe('System Python fallback', () => { + let execSpy: jest.SpyInstance; + let manifestSpy: jest.SpyInstance; + + beforeEach(() => { + // Mock the manifest to return entries only for x64, not riscv64 + manifestSpy = jest.spyOn(installer, 'getManifest'); + manifestSpy.mockImplementation( + async () => manifestData + ); + }); + + it('Falls back to system Python on unsupported architecture', async () => { + execSpy = jest.spyOn(exec, 'getExecOutput'); + execSpy.mockImplementation(async () => ({ + exitCode: 0, + stdout: + '/usr/bin/python3\n3.12.0\n/usr\n/usr/bin\n', + stderr: '' + })); + + const result = await finder.useCpythonVersion( + '3.12', + 'riscv64', + true, + false, + false, + false + ); + + expect(result).toEqual({impl: 'CPython', version: '3.12.0'}); + expect(spyCoreAddPath).toHaveBeenCalledWith('/usr/bin'); + expect(spyCoreExportVariable).toHaveBeenCalledWith( + 'pythonLocation', + '/usr' + ); + }); + + it('Does not fall back on supported architecture with missing version', async () => { + // x64 has manifest entries, so fallback should NOT trigger + let thrown = false; + try { + await finder.useCpythonVersion( + '3.300000', + 'x64', + true, + false, + false, + false + ); + } catch { + thrown = true; + } + expect(thrown).toBeTruthy(); + }); + + it('Does not fall back when system Python version does not match', async () => { + execSpy = jest.spyOn(exec, 'getExecOutput'); + execSpy.mockImplementation(async () => ({ + exitCode: 0, + stdout: + '/usr/bin/python3\n3.11.5\n/usr\n/usr/bin\n', + stderr: '' + })); + + let thrown = false; + try { + await finder.useCpythonVersion( + '3.12', + 'riscv64', + true, + false, + false, + false + ); + } catch { + thrown = true; + } + expect(thrown).toBeTruthy(); + }); + + it('Does not fall back for freethreaded builds', async () => { + execSpy = jest.spyOn(exec, 'getExecOutput'); + execSpy.mockImplementation(async () => ({ + exitCode: 0, + stdout: + '/usr/bin/python3\n3.13.0\n/usr\n/usr/bin\n', + stderr: '' + })); + + let thrown = false; + try { + await finder.useCpythonVersion( + '3.13t', + 'riscv64', + true, + false, + false, + false + ); + } catch { + thrown = true; + } + expect(thrown).toBeTruthy(); + }); + + it('Handles missing system Python gracefully', async () => { + execSpy = jest.spyOn(exec, 'getExecOutput'); + execSpy.mockImplementation(async () => { + throw new Error('python3 not found'); + }); + + let thrown = false; + try { + await finder.useCpythonVersion( + '3.12', + 'riscv64', + true, + false, + false, + false + ); + } catch { + thrown = true; + } + expect(thrown).toBeTruthy(); + }); + }); }); diff --git a/dist/setup/index.js b/dist/setup/index.js index 41363d8b..1a27fe9d 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -82997,6 +82997,45 @@ async function useCpythonVersion(version, architecture, updateEnvironment, check installDir = tc.find('Python', semanticVersionSpec, architecture); } } + if (!installDir && !freethreaded) { + // Try system Python as fallback, but only for architectures that have + // no pre-built binaries in the manifest at all. This prevents the + // fallback from firing when a specific version is missing on a + // supported architecture (e.g., requesting Python 3.99 on x86_64). + const baseArchitecture = architecture.replace('-freethreaded', ''); + if (!manifest) { + manifest = await installer.getManifest(); + } + const archHasManifestEntries = manifest?.some(release => release.files?.some((file) => file.arch === baseArchitecture)); + if (!archHasManifestEntries) { + try { + const sysInfo = await exec.getExecOutput('python3', [ + '-c', + 'import sys, os; print(sys.executable + "\\n" + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + "\\n" + sys.prefix + "\\n" + os.path.dirname(sys.executable))' + ]); + if (sysInfo.exitCode === 0) { + const [sysExecutable, sysVersion, sysPrefix, sysBinDir] = sysInfo.stdout.trim().split('\n'); + if (semver.satisfies(sysVersion, semanticVersionSpec)) { + core.warning(`Pre-built Python not available for architecture '${baseArchitecture}'. Using system Python ${sysVersion} at ${sysExecutable}.`); + if (updateEnvironment) { + core.exportVariable('pythonLocation', sysPrefix); + core.exportVariable('PKG_CONFIG_PATH', sysPrefix + '/lib/pkgconfig'); + core.exportVariable('Python_ROOT_DIR', sysPrefix); + core.exportVariable('Python2_ROOT_DIR', sysPrefix); + core.exportVariable('Python3_ROOT_DIR', sysPrefix); + core.addPath(sysBinDir); + } + core.setOutput('python-version', sysVersion); + core.setOutput('python-path', sysExecutable); + return { impl: 'CPython', version: sysVersion }; + } + } + } + catch { + // System Python not available, fall through to error + } + } + } if (!installDir) { const osInfo = await (0, utils_1.getOSInfo)(); const msg = [ diff --git a/src/find-python.ts b/src/find-python.ts index 99c6a7f2..ed1187ce 100644 --- a/src/find-python.ts +++ b/src/find-python.ts @@ -122,6 +122,60 @@ export async function useCpythonVersion( } } + if (!installDir && !freethreaded) { + // Try system Python as fallback, but only for architectures that have + // no pre-built binaries in the manifest at all. This prevents the + // fallback from firing when a specific version is missing on a + // supported architecture (e.g., requesting Python 3.99 on x86_64). + const baseArchitecture = architecture.replace('-freethreaded', ''); + if (!manifest) { + manifest = await installer.getManifest(); + } + const archHasManifestEntries = manifest?.some( + release => + release.files?.some( + (file: {arch: string}) => file.arch === baseArchitecture + ) + ); + + if (!archHasManifestEntries) { + try { + const sysInfo = await exec.getExecOutput('python3', [ + '-c', + 'import sys, os; print(sys.executable + "\\n" + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + "\\n" + sys.prefix + "\\n" + os.path.dirname(sys.executable))' + ]); + if (sysInfo.exitCode === 0) { + const [sysExecutable, sysVersion, sysPrefix, sysBinDir] = + sysInfo.stdout.trim().split('\n'); + if (semver.satisfies(sysVersion, semanticVersionSpec)) { + core.warning( + `Pre-built Python not available for architecture '${baseArchitecture}'. Using system Python ${sysVersion} at ${sysExecutable}.` + ); + + if (updateEnvironment) { + core.exportVariable('pythonLocation', sysPrefix); + core.exportVariable( + 'PKG_CONFIG_PATH', + sysPrefix + '/lib/pkgconfig' + ); + core.exportVariable('Python_ROOT_DIR', sysPrefix); + core.exportVariable('Python2_ROOT_DIR', sysPrefix); + core.exportVariable('Python3_ROOT_DIR', sysPrefix); + core.addPath(sysBinDir); + } + + core.setOutput('python-version', sysVersion); + core.setOutput('python-path', sysExecutable); + + return {impl: 'CPython', version: sysVersion}; + } + } + } catch { + // System Python not available, fall through to error + } + } + } + if (!installDir) { const osInfo = await getOSInfo(); const msg = [