From ce013a24a497e285537eb51df12516439813e810 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 3 Sep 2026 15:35:14 -0400 Subject: [PATCH] Simplify Python runtime probing and tests --- __tests__/utils/python-runtime.test.ts | 257 ++++++++----------------- dist/setup/index.cjs | 66 ++----- src/utils/python-runtime.ts | 12 +- 3 files changed, 103 insertions(+), 232 deletions(-) diff --git a/__tests__/utils/python-runtime.test.ts b/__tests__/utils/python-runtime.test.ts index 7981fa6..328a73f 100644 --- a/__tests__/utils/python-runtime.test.ts +++ b/__tests__/utils/python-runtime.test.ts @@ -1,33 +1,29 @@ -import * as path from "node:path"; -import type * as exec from "@actions/exec"; -import { - afterEach, - beforeEach, - describe, - expect, - it, - jest, -} from "@jest/globals"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, expect, it, jest } from "@jest/globals"; import { createSetupInputs } from "../helpers/setup-inputs"; -const mockGetExecOutput = jest.fn(); +const mockExecFile = + jest.fn< + (...args: unknown[]) => Promise<{ stdout: string; stderr: string }> + >(); const mockDebug = jest.fn(); const originalPlatform = process.platform; +const inputs = createSetupInputs({ + activateEnvironment: true, + pythonVersion: "3.15t", +}); -jest.unstable_mockModule("@actions/core", () => ({ - debug: mockDebug, - isDebug: jest.fn(() => false), -})); - -jest.unstable_mockModule("@actions/exec", () => ({ - getExecOutput: mockGetExecOutput, +jest.unstable_mockModule("@actions/core", () => ({ debug: mockDebug })); +jest.unstable_mockModule("node:child_process", () => ({ + // execFile's custom promisifier returns both stdout and stderr. + execFile: Object.assign(mockExecFile, { [promisify.custom]: mockExecFile }), })); const { getPythonRuntimeId } = await import("../../src/utils/python-runtime"); function mockRuntime(overrides: Record = {}) { - mockGetExecOutput.mockResolvedValue({ - exitCode: 0, + mockExecFile.mockResolvedValue({ stderr: "", stdout: `${JSON.stringify({ freethreaded: false, @@ -40,171 +36,76 @@ function mockRuntime(overrides: Record = {}) { } beforeEach(() => { - mockGetExecOutput.mockReset(); + mockExecFile.mockReset(); mockRuntime(); }); - afterEach(() => { Object.defineProperty(process, "platform", { value: originalPlatform }); }); -describe("getPythonRuntimeId", () => { - it("does not query Python when environment activation is disabled", async () => { - const inputs = createSetupInputs({ pythonVersion: "3.15t" }); +it("does not query Python without environment activation", async () => { + expect( + await getPythonRuntimeId({ ...inputs, activateEnvironment: false }), + ).toBe(""); + expect(mockExecFile).not.toHaveBeenCalled(); +}); - expect(await getPythonRuntimeId(inputs)).toBe(""); - expect(mockGetExecOutput).not.toHaveBeenCalled(); +it.each([ + ["3.13.1", false, "cpython-3.13.1"], + ["3.15.0a1", false, "cpython-3.15.0a1"], + ["3.15.0b2", false, "cpython-3.15.0b2"], + ["3.15.0rc1", false, "cpython-3.15.0rc1"], + ["3.15.0rc1", true, "cpython-3.15.0rc1-freethreaded"], + ["3.15.0rc2", true, "cpython-3.15.0rc2-freethreaded"], + ["3.15.0", true, "cpython-3.15.0-freethreaded"], +])("formats CPython %s, free-threading=%s", async (version, free, expected) => { + mockRuntime({ freethreaded: free, pythonVersion: version }); + expect(await getPythonRuntimeId(inputs)).toBe(expected); +}); + +it.each([ + ["pypy", [7, 3, 23, "final", 0], "pypy-7.3.23"], + ["pypy", [7, 3, 24, "final", 0], "pypy-7.3.24"], + ["pypy", [7, 3, 24, "alpha", 1], "pypy-7.3.24a1"], + ["pypy", [7, 3, 24, "beta", 2], "pypy-7.3.24b2"], + ["pypy", [7, 3, 24, "candidate", 3], "pypy-7.3.24rc3"], + ["graalpy", [25, 0, 0, "final", 0], "graalpy-25.0.0"], + ["pypy", [7, 3, 24, "unknown", 0], ""], +])("formats %s implementation version %j", async (name, version, expected) => { + mockRuntime({ + implementation: name, + implementationVersion: version, + pythonVersion: "3.11.15", }); - - it("identifies the resolved CPython version rather than the request", async () => { - const inputs = createSetupInputs({ - activateEnvironment: true, - pythonVersion: "3.13", - }); - - expect(await getPythonRuntimeId(inputs)).toBe("cpython-3.13.1"); - expect(inputs.pythonVersion).toBe("3.13"); - }); - - it.each([ - ["3.15.0a1", false, "cpython-3.15.0a1"], - ["3.15.0b2", false, "cpython-3.15.0b2"], - ["3.15.0rc1", false, "cpython-3.15.0rc1"], - ["3.15.0rc1", true, "cpython-3.15.0rc1-freethreaded"], - ["3.15.0rc2", true, "cpython-3.15.0rc2-freethreaded"], - ["3.15.0", true, "cpython-3.15.0-freethreaded"], - ])( - "preserves version %s and free-threading=%s as %s", - async (pythonVersion, freethreaded, expected) => { - mockRuntime({ freethreaded, pythonVersion }); - - expect( - await getPythonRuntimeId( - createSetupInputs({ activateEnvironment: true }), - ), - ).toBe(expected); - }, - ); - - it("derives free-threading from the interpreter rather than the input", async () => { - const inputs = createSetupInputs({ - activateEnvironment: true, - pythonVersion: "3.13.1t", - }); - - expect(await getPythonRuntimeId(inputs)).toBe("cpython-3.13.1"); - }); - - it.each([ - [[7, 3, 23, "final", 0], "pypy-7.3.23-python-3.11.15"], - [[7, 3, 24, "final", 0], "pypy-7.3.24-python-3.11.15"], - [[7, 3, 24, "alpha", 1], "pypy-7.3.24a1-python-3.11.15"], - [[7, 3, 24, "beta", 2], "pypy-7.3.24b2-python-3.11.15"], - [[7, 3, 24, "candidate", 3], "pypy-7.3.24rc3-python-3.11.15"], - ])("includes PyPy implementation version %j", async (version, expected) => { - mockRuntime({ - implementation: "pypy", - implementationVersion: version, - pythonVersion: "3.11.15", - }); - - expect( - await getPythonRuntimeId( - createSetupInputs({ activateEnvironment: true }), - ), - ).toBe(expected); - }); - - it("includes the name and version of other Python implementations", async () => { - mockRuntime({ - implementation: "graalpy", - implementationVersion: [25, 0, 0, "final", 0], - pythonVersion: "3.12.8", - }); - - expect( - await getPythonRuntimeId( - createSetupInputs({ activateEnvironment: true }), - ), - ).toBe("graalpy-25.0.0-python-3.12.8"); - }); - - it.each(["linux", "darwin", "win32"])( - "queries the custom venv directly on %s, including paths with spaces", - async (platform) => { - Object.defineProperty(process, "platform", { value: platform }); - const inputs = createSetupInputs({ - activateEnvironment: true, - venvPath: "/runner temp/custom venv", - workingDirectory: "/different/project", - }); - - await getPythonRuntimeId(inputs); - - const pythonPath = - platform === "win32" - ? path.join(inputs.venvPath, "Scripts", "python.exe") - : path.join(inputs.venvPath, "bin", "python"); - expect(mockGetExecOutput).toHaveBeenCalledWith( - `"${pythonPath}"`, - ["-I", "-c", expect.any(String)], - { silent: true }, - ); - }, - ); - - it("escapes quotes in the venv path for @actions/exec", async () => { - Object.defineProperty(process, "platform", { value: "linux" }); - const inputs = createSetupInputs({ - activateEnvironment: true, - venvPath: '/workspace/a "quoted" venv', - }); - - await getPythonRuntimeId(inputs); - - expect(mockGetExecOutput.mock.calls[0][0]).toContain('a \\"quoted\\" venv'); - }); - - it.each(["", "not JSON", "null", "{}"])( - "returns an empty output for invalid interpreter output: %j", - async (stdout) => { - mockGetExecOutput.mockResolvedValue({ exitCode: 0, stderr: "", stdout }); - - expect( - await getPythonRuntimeId( - createSetupInputs({ activateEnvironment: true }), - ), - ).toBe(""); - expect(mockDebug).toHaveBeenCalled(); - }, - ); - - it("returns an empty output for an invalid implementation version", async () => { - mockRuntime({ - implementation: "pypy", - implementationVersion: [7, 3, 24, "unknown", 0], - }); - - expect( - await getPythonRuntimeId( - createSetupInputs({ activateEnvironment: true }), - ), - ).toBe(""); - }); - - it.each([new Error("interpreter missing"), "interpreter failed"])( - "returns an empty output if the interpreter cannot be queried: %s", - async (error) => { - mockGetExecOutput.mockRejectedValue(error); - - expect( - await getPythonRuntimeId( - createSetupInputs({ activateEnvironment: true }), - ), - ).toBe(""); - expect(mockDebug).toHaveBeenCalledWith( - expect.stringContaining(error instanceof Error ? error.message : error), - ); - }, + expect(await getPythonRuntimeId(inputs)).toBe( + expected ? `${expected}-python-3.11.15` : "", ); }); + +it.each([ + ["linux", 'a "quoted" venv', "bin/python"], + ["darwin", "custom venv", "bin/python"], + ["win32", "custom venv", "Scripts/python.exe"], +])("passes the venv executable directly on %s", async (platform, name, exe) => { + Object.defineProperty(process, "platform", { value: platform }); + const venvPath = join("/runner temp", name); + await getPythonRuntimeId({ ...inputs, venvPath }); + expect(mockExecFile).toHaveBeenCalledWith( + join(venvPath, exe), + ["-I", "-c", expect.any(String)], + { encoding: "utf8" }, + ); +}); + +it.each([new Error("interpreter missing"), "", "not JSON", "null", "{}"])( + "returns an empty ID for interpreter failure: %s", + async (result) => { + if (result instanceof Error) { + mockExecFile.mockRejectedValue(result); + } else { + mockExecFile.mockResolvedValue({ stderr: "", stdout: result }); + } + expect(await getPythonRuntimeId(inputs)).toBe(""); + expect(mockDebug).toHaveBeenCalled(); + }, +); diff --git a/dist/setup/index.cjs b/dist/setup/index.cjs index 427988f..9c633ce 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -10833,7 +10833,7 @@ var require_mock_interceptor = __commonJS({ var require_mock_client = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-client.js"(exports2, module2) { "use strict"; - var { promisify: promisify5 } = require("node:util"); + var { promisify: promisify6 } = require("node:util"); var Client = require_client(); var { buildMockDispatch } = require_mock_utils(); var { @@ -10873,7 +10873,7 @@ var require_mock_client = __commonJS({ return new MockInterceptor(opts, this[kDispatches]); } async [kClose]() { - await promisify5(this[kOriginalClose])(); + await promisify6(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -10886,7 +10886,7 @@ var require_mock_client = __commonJS({ var require_mock_pool = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) { "use strict"; - var { promisify: promisify5 } = require("node:util"); + var { promisify: promisify6 } = require("node:util"); var Pool = require_pool(); var { buildMockDispatch } = require_mock_utils(); var { @@ -10926,7 +10926,7 @@ var require_mock_pool = __commonJS({ return new MockInterceptor(opts, this[kDispatches]); } async [kClose]() { - await promisify5(this[kOriginalClose])(); + await promisify6(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -14676,7 +14676,7 @@ var require_util4 = __commonJS({ var { getEncoding } = require_encoding(); var { serializeAMimeType, parseMIMEType } = require_data_url(); var { types: types2 } = require("node:util"); - var { StringDecoder: StringDecoder2 } = require("string_decoder"); + var { StringDecoder } = require("string_decoder"); var { btoa: btoa2 } = require("node:buffer"); var staticPropertyDescriptors = { enumerable: true, @@ -14767,7 +14767,7 @@ var require_util4 = __commonJS({ dataURL += serializeAMimeType(parsed); } dataURL += ";base64,"; - const decoder = new StringDecoder2("latin1"); + const decoder = new StringDecoder("latin1"); for (const chunk of bytes) { dataURL += btoa2(decoder.write(chunk)); } @@ -14796,7 +14796,7 @@ var require_util4 = __commonJS({ } case "BinaryString": { let binaryString = ""; - const decoder = new StringDecoder2("latin1"); + const decoder = new StringDecoder("latin1"); for (const chunk of bytes) { binaryString += decoder.write(chunk); } @@ -43099,7 +43099,7 @@ var require_mock_interceptor2 = __commonJS({ var require_mock_client2 = __commonJS({ "node_modules/undici/lib/mock/mock-client.js"(exports2, module2) { "use strict"; - var { promisify: promisify5 } = require("node:util"); + var { promisify: promisify6 } = require("node:util"); var Client = require_client2(); var { buildMockDispatch } = require_mock_utils2(); var { @@ -43147,7 +43147,7 @@ var require_mock_client2 = __commonJS({ this[kDispatches] = []; } async [kClose]() { - await promisify5(this[kOriginalClose])(); + await promisify6(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -43360,7 +43360,7 @@ var require_mock_call_history = __commonJS({ var require_mock_pool2 = __commonJS({ "node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) { "use strict"; - var { promisify: promisify5 } = require("node:util"); + var { promisify: promisify6 } = require("node:util"); var Pool = require_pool2(); var { buildMockDispatch } = require_mock_utils2(); var { @@ -43408,7 +43408,7 @@ var require_mock_pool2 = __commonJS({ this[kDispatches] = []; } async [kClose]() { - await promisify5(this[kOriginalClose])(); + await promisify6(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -58186,9 +58186,6 @@ var _summary = new Summary(); // node_modules/@actions/core/lib/platform.js var import_os2 = __toESM(require("os"), 1); -// node_modules/@actions/exec/lib/exec.js -var import_string_decoder = require("string_decoder"); - // node_modules/@actions/exec/lib/toolrunner.js var os3 = __toESM(require("os"), 1); var events = __toESM(require("events"), 1); @@ -59009,38 +59006,6 @@ function exec(commandLine, args, options) { return runner.exec(); }); } -function getExecOutput(commandLine, args, options) { - return __awaiter7(this, void 0, void 0, function* () { - var _a2, _b; - let stdout = ""; - let stderr = ""; - const stdoutDecoder = new import_string_decoder.StringDecoder("utf8"); - const stderrDecoder = new import_string_decoder.StringDecoder("utf8"); - const originalStdoutListener = (_a2 = options === null || options === void 0 ? void 0 : options.listeners) === null || _a2 === void 0 ? void 0 : _a2.stdout; - const originalStdErrListener = (_b = options === null || options === void 0 ? void 0 : options.listeners) === null || _b === void 0 ? void 0 : _b.stderr; - const stdErrListener = (data) => { - stderr += stderrDecoder.write(data); - if (originalStdErrListener) { - originalStdErrListener(data); - } - }; - const stdOutListener = (data) => { - stdout += stdoutDecoder.write(data); - if (originalStdoutListener) { - originalStdoutListener(data); - } - }; - const listeners = Object.assign(Object.assign({}, options === null || options === void 0 ? void 0 : options.listeners), { stdout: stdOutListener, stderr: stdErrListener }); - const exitCode = yield exec(commandLine, args, Object.assign(Object.assign({}, options), { listeners })); - stdout += stdoutDecoder.end(); - stderr += stderrDecoder.end(); - return { - exitCode, - stdout, - stderr - }; - }); -} // node_modules/@actions/core/lib/platform.js var platform = import_os2.default.platform(); @@ -102102,7 +102067,10 @@ function getResolutionStrategy() { } // src/utils/python-runtime.ts +var import_node_child_process = require("node:child_process"); var import_node_path2 = require("node:path"); +var import_node_util4 = require("node:util"); +var execFileAsync = (0, import_node_util4.promisify)(import_node_child_process.execFile); var PYTHON_RUNTIME_QUERY = ` import json import platform @@ -102141,10 +102109,10 @@ async function getPythonRuntimeId(inputs) { } const pythonPath = process.platform === "win32" ? (0, import_node_path2.join)(inputs.venvPath, "Scripts", "python.exe") : (0, import_node_path2.join)(inputs.venvPath, "bin", "python"); try { - const { stdout } = await getExecOutput( - `"${pythonPath.replace(/"/g, '\\"')}"`, + const { stdout } = await execFileAsync( + pythonPath, ["-I", "-c", PYTHON_RUNTIME_QUERY], - { silent: !isDebug() } + { encoding: "utf8" } ); return formatRuntimeId(JSON.parse(stdout)); } catch (error2) { diff --git a/src/utils/python-runtime.ts b/src/utils/python-runtime.ts index f183515..0b5651c 100644 --- a/src/utils/python-runtime.ts +++ b/src/utils/python-runtime.ts @@ -1,8 +1,11 @@ +import { execFile } from "node:child_process"; import { join } from "node:path"; +import { promisify } from "node:util"; import * as core from "@actions/core"; -import * as exec from "@actions/exec"; import type { SetupInputs } from "./inputs"; +const execFileAsync = promisify(execFile); + const PYTHON_RUNTIME_QUERY = ` import json import platform @@ -69,11 +72,10 @@ export async function getPythonRuntimeId(inputs: SetupInputs): Promise { : join(inputs.venvPath, "bin", "python"); try { - // @actions/exec parses the executable as a command line, so quote the path. - const { stdout } = await exec.getExecOutput( - `"${pythonPath.replace(/"/g, '\\"')}"`, + const { stdout } = await execFileAsync( + pythonPath, ["-I", "-c", PYTHON_RUNTIME_QUERY], - { silent: !core.isDebug() }, + { encoding: "utf8" }, ); return formatRuntimeId(JSON.parse(stdout)); } catch (error) {