diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2221d44..ef2a443 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -347,9 +347,14 @@ jobs: if [ "$PYTHON_VERSION" != "3.13.1t" ]; then exit 1 fi + if [ -n "$PYTHON_VERSION_RESOLVED" ]; then + echo "python-version-resolved should be empty without environment activation" + exit 1 + fi shell: bash env: PYTHON_VERSION: ${{ steps.setup-uv.outputs.python-version }} + PYTHON_VERSION_RESOLVED: ${{ steps.setup-uv.outputs.python-version-resolved }} - run: uv sync working-directory: __tests__/fixtures/uv-project @@ -358,23 +363,46 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.13.1t"] + include: + - os: ubuntu-latest + python-version: "3.13" + - os: ubuntu-latest + python-version: "3.14.0rc2" + - os: ubuntu-latest + python-version: "pypy3.11" steps: - name: Install latest version id: setup-uv uses: $/ with: - python-version: 3.13.1t + python-version: ${{ matrix.python-version }} activate-environment: true + enable-cache: true - name: Verify packages can be installed run: uv pip install pip shell: bash - - name: Verify python version is correct + - name: Verify Python version outputs and cache key run: | - python --version - if [ "$(python --version)" != "Python 3.13.1" ]; then + expected=$(python -I -c 'import platform; print(platform.python_version())') + if [ "$PYTHON_VERSION_RESOLVED" != "$expected" ]; then + echo "Wrong resolved Python version: $PYTHON_VERSION_RESOLVED (expected $expected)" exit 1 fi + if [ "$PYTHON_VERSION" != "$PYTHON_REQUEST" ]; then + echo "Wrong requested Python version: $PYTHON_VERSION" + exit 1 + fi + case "$CACHE_KEY" in + *-"$PYTHON_REQUEST"-*) ;; + *) echo "Cache key no longer contains requested Python version: $CACHE_KEY"; exit 1 ;; + esac shell: bash + env: + CACHE_KEY: ${{ steps.setup-uv.outputs.cache-key }} + PYTHON_REQUEST: ${{ matrix.python-version }} + PYTHON_VERSION: ${{ steps.setup-uv.outputs.python-version }} + PYTHON_VERSION_RESOLVED: ${{ steps.setup-uv.outputs.python-version-resolved }} - name: Verify output venv is set run: | if [ -z "$UV_VENV" ]; then @@ -439,6 +467,15 @@ jobs: raise SystemExit(f"Python is not running from custom venv: {sys.executable}") PY shell: bash + - name: Verify resolved Python version from custom venv + run: | + if [ "$PYTHON_VERSION_RESOLVED" != "3.13.1" ]; then + echo "Wrong resolved Python version: $PYTHON_VERSION_RESOLVED" + exit 1 + fi + shell: bash + env: + PYTHON_VERSION_RESOLVED: ${{ steps.setup-uv.outputs.python-version-resolved }} test-activate-environment-no-project: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 260224d..bada3fd 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed - `cache-hit`: A boolean value to indicate a cache entry was found. - `venv`: Path to the activated venv if activate-environment is true. - `python-version`: The Python version that was set. +- `python-version-resolved`: The full Python version of the activated venv, including prerelease suffixes. + Empty when `activate-environment` is false or the version cannot be determined. - `python-cache-hit`: A boolean value to indicate the Python cache entry was found. ### Python version diff --git a/__tests__/utils/python-version.test.ts b/__tests__/utils/python-version.test.ts new file mode 100644 index 0000000..b8d7fc4 --- /dev/null +++ b/__tests__/utils/python-version.test.ts @@ -0,0 +1,131 @@ +import * as path from "node:path"; +import type * as exec from "@actions/exec"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; +import { createSetupInputs } from "../helpers/setup-inputs"; + +const mockGetExecOutput = jest.fn(); +const mockDebug = jest.fn(); +const originalPlatform = process.platform; + +jest.unstable_mockModule("@actions/core", () => ({ + debug: mockDebug, + isDebug: jest.fn(() => false), +})); + +jest.unstable_mockModule("@actions/exec", () => ({ + getExecOutput: mockGetExecOutput, +})); + +const { getResolvedPythonVersion } = await import( + "../../src/utils/python-version" +); + +beforeEach(() => { + mockGetExecOutput.mockReset(); +}); + +afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); +}); + +describe("getResolvedPythonVersion", () => { + it("does not query Python when environment activation is disabled", async () => { + const inputs = createSetupInputs({ pythonVersion: "3.15t" }); + + expect(await getResolvedPythonVersion(inputs)).toBe(""); + expect(mockGetExecOutput).not.toHaveBeenCalled(); + }); + + it.each([ + ["3.13", "3.13.1"], + ["3.13.1t", "3.13.1"], + ["3.15t", "3.15.0rc1"], + ["pypy3.11", "3.11.11"], + ["", "3.12.9"], + ])( + "resolves %j to the interpreter's version %s", + async (request, version) => { + mockGetExecOutput.mockResolvedValue({ + exitCode: 0, + stderr: "", + stdout: `${version}\r\n`, + }); + const inputs = createSetupInputs({ + activateEnvironment: true, + pythonVersion: request, + }); + + expect(await getResolvedPythonVersion(inputs)).toBe(version); + expect(inputs.pythonVersion).toBe(request); + }, + ); + + it.each(["linux", "darwin", "win32"])( + "queries the custom venv directly on %s, including paths with spaces", + async (platform) => { + Object.defineProperty(process, "platform", { value: platform }); + mockGetExecOutput.mockResolvedValue({ + exitCode: 0, + stderr: "", + stdout: "3.13.1\n", + }); + const inputs = createSetupInputs({ + activateEnvironment: true, + venvPath: "/runner temp/custom venv", + workingDirectory: "/different/project", + }); + + await getResolvedPythonVersion(inputs); + + const pythonPath = + platform === "win32" + ? path.join(inputs.venvPath, "Scripts", "python.exe") + : path.join(inputs.venvPath, "bin", "python"); + expect(mockGetExecOutput).toHaveBeenCalledWith( + `"${pythonPath}"`, + ["-I", "-c", "import platform; print(platform.python_version())"], + { silent: true }, + ); + }, + ); + + it("escapes quotes in the venv path for @actions/exec", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + mockGetExecOutput.mockResolvedValue({ + exitCode: 0, + stderr: "", + stdout: "3.13.1\n", + }); + const inputs = createSetupInputs({ + activateEnvironment: true, + venvPath: '/workspace/a "quoted" venv', + }); + + await getResolvedPythonVersion(inputs); + + expect(mockGetExecOutput.mock.calls[0][0]).toContain('a \\"quoted\\" venv'); + }); + + 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 getResolvedPythonVersion( + createSetupInputs({ activateEnvironment: true }), + ), + ).toBe(""); + expect(mockDebug).toHaveBeenCalledWith( + expect.stringContaining(error instanceof Error ? error.message : error), + ); + }, + ); +}); diff --git a/action-types.yml b/action-types.yml index 20c6ec4..c4a6c5f 100644 --- a/action-types.yml +++ b/action-types.yml @@ -79,5 +79,7 @@ outputs: type: string python-version: type: string + python-version-resolved: + type: string python-cache-hit: type: boolean diff --git a/action.yml b/action.yml index 50b05ec..0ac0988 100644 --- a/action.yml +++ b/action.yml @@ -107,6 +107,8 @@ outputs: description: "Path to the activated venv if activate-environment is true" python-version: description: "The Python version that was set." + python-version-resolved: + description: "The full Python version of the activated venv, including prerelease suffixes. Empty when activate-environment is false or the version cannot be determined." python-cache-hit: description: "A boolean value to indicate the Python cache entry was found" runs: diff --git a/dist/setup/index.cjs b/dist/setup/index.cjs index 2927ea2..bfd1db1 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -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 } = require("string_decoder"); + var { StringDecoder: StringDecoder2 } = 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 StringDecoder("latin1"); + const decoder = new StringDecoder2("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 StringDecoder("latin1"); + const decoder = new StringDecoder2("latin1"); for (const chunk of bytes) { binaryString += decoder.write(chunk); } @@ -58186,6 +58186,9 @@ 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); @@ -59006,6 +59009,38 @@ 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(); @@ -102066,6 +102101,28 @@ function getResolutionStrategy() { ); } +// src/utils/python-version.ts +var import_node_path2 = require("node:path"); +async function getResolvedPythonVersion(inputs) { + if (!inputs.activateEnvironment) { + return ""; + } + 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, '\\"')}"`, + ["-I", "-c", "import platform; print(platform.python_version())"], + { silent: !isDebug() } + ); + return stdout.trim(); + } catch (error2) { + debug( + `Failed to get the activated environment's Python version. Error: ${error2 instanceof Error ? error2.message : String(error2)}` + ); + return ""; + } +} + // src/setup-uv.ts var sourceDir = __dirname; function formatUnexpectedFailure(error2) { @@ -102136,6 +102193,10 @@ async function run() { info2(`Successfully installed uv version ${setupResult.version}`); const detectedPythonVersion = await getPythonVersion2(inputs); setOutput("python-version", detectedPythonVersion); + setOutput( + "python-version-resolved", + await getResolvedPythonVersion(inputs) + ); if (inputs.enableCache) { await restoreCache2(inputs, detectedPythonVersion); } diff --git a/docs/environment-and-tools.md b/docs/environment-and-tools.md index 869d855..ea49560 100644 --- a/docs/environment-and-tools.md +++ b/docs/environment-and-tools.md @@ -17,6 +17,19 @@ This allows directly using it in later steps: By default, the venv is created at `.venv` inside the `working-directory`. +With `activate-environment: true`, the `python-version-resolved` output contains the +venv's full Python version, as reported by `platform.python_version()`. For example, +a `python-version` input of `3.15t` can resolve to `3.15.0rc1`. Use +`${{ steps.setup-uv.outputs.python-version-resolved }}` in later steps to access +the resolved version (with `id: setup-uv` on the setup step). + +This is the Python language version: it includes prerelease suffixes, but no +implementation name or free-threaded marker. For cache keys that distinguish PyPy +from CPython or free-threaded builds, include the requested version as well. +The output is empty when `activate-environment` is false or the version cannot be +determined. The existing `python-version` output and setup-uv's cache keys are +unaffected. + You can customize the venv location with `venv-path`, for example to place it in the runner temp directory: ```yaml diff --git a/src/setup-uv.ts b/src/setup-uv.ts index 836ce57..74edbea 100644 --- a/src/setup-uv.ts +++ b/src/setup-uv.ts @@ -16,6 +16,7 @@ import { getPlatform, type Platform, } from "./utils/platforms"; +import { getResolvedPythonVersion } from "./utils/python-version"; import { resolveUvVersion } from "./version/resolve"; const sourceDir = __dirname; @@ -101,6 +102,10 @@ async function run(): Promise { const detectedPythonVersion = await getPythonVersion(inputs); core.setOutput("python-version", detectedPythonVersion); + core.setOutput( + "python-version-resolved", + await getResolvedPythonVersion(inputs), + ); if (inputs.enableCache) { await restoreCache(inputs, detectedPythonVersion); diff --git a/src/utils/python-version.ts b/src/utils/python-version.ts new file mode 100644 index 0000000..ff06fb2 --- /dev/null +++ b/src/utils/python-version.ts @@ -0,0 +1,32 @@ +import { join } from "node:path"; +import * as core from "@actions/core"; +import * as exec from "@actions/exec"; +import type { SetupInputs } from "./inputs"; + +export async function getResolvedPythonVersion( + inputs: SetupInputs, +): Promise { + if (!inputs.activateEnvironment) { + return ""; + } + + const pythonPath = + process.platform === "win32" + ? join(inputs.venvPath, "Scripts", "python.exe") + : 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, '\\"')}"`, + ["-I", "-c", "import platform; print(platform.python_version())"], + { silent: !core.isDebug() }, + ); + return stdout.trim(); + } catch (error) { + core.debug( + `Failed to get the activated environment's Python version. Error: ${error instanceof Error ? error.message : String(error)}`, + ); + return ""; + } +}