From 70e5842accc969ce767d7d01468399fca079d4a3 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 3 Sep 2026 15:18:29 -0400 Subject: [PATCH] Expose Python runtime identity for downstream caching --- .github/workflows/test.yml | 42 +++-- README.md | 5 +- __tests__/utils/python-runtime.test.ts | 210 +++++++++++++++++++++++++ __tests__/utils/python-version.test.ts | 131 --------------- action-types.yml | 2 +- action.yml | 4 +- dist/setup/index.cjs | 47 ++++-- docs/environment-and-tools.md | 39 +++-- src/setup-uv.ts | 7 +- src/utils/python-runtime.ts | 85 ++++++++++ src/utils/python-version.ts | 32 ---- 11 files changed, 400 insertions(+), 204 deletions(-) create mode 100644 __tests__/utils/python-runtime.test.ts delete mode 100644 __tests__/utils/python-version.test.ts create mode 100644 src/utils/python-runtime.ts delete mode 100644 src/utils/python-version.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef2a443..c4a4ab8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -347,14 +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" + if [ -n "$PYTHON_RUNTIME_ID" ]; then + echo "python-runtime-id 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 }} + PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }} - run: uv sync working-directory: __tests__/fixtures/uv-project @@ -369,6 +369,8 @@ jobs: python-version: "3.13" - os: ubuntu-latest python-version: "3.14.0rc2" + - os: ubuntu-latest + python-version: "3.14.0rc2t" - os: ubuntu-latest python-version: "pypy3.11" steps: @@ -384,9 +386,27 @@ jobs: shell: bash - name: Verify Python version outputs and cache key run: | - 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)" + expected=$(python -I - <<'PY' + import platform + import sys + import sysconfig + + if sys.implementation.name == "cpython": + runtime = f"cpython-{platform.python_version()}" + else: + version = sys.implementation.version + implementation_version = f"{version.major}.{version.minor}.{version.micro}" + suffix = {"alpha": "a", "beta": "b", "candidate": "rc", "final": ""}[version.releaselevel] + if suffix: + implementation_version += f"{suffix}{version.serial}" + runtime = f"{sys.implementation.name}-{implementation_version}-python-{platform.python_version()}" + if sysconfig.get_config_var("Py_GIL_DISABLED") == 1: + runtime += "-freethreaded" + print(runtime) + PY + ) + if [ "$PYTHON_RUNTIME_ID" != "$expected" ]; then + echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID (expected $expected)" exit 1 fi if [ "$PYTHON_VERSION" != "$PYTHON_REQUEST" ]; then @@ -402,7 +422,7 @@ jobs: 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 }} + PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }} - name: Verify output venv is set run: | if [ -z "$UV_VENV" ]; then @@ -467,15 +487,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 + - name: Verify Python runtime ID from custom venv run: | - if [ "$PYTHON_VERSION_RESOLVED" != "3.13.1" ]; then - echo "Wrong resolved Python version: $PYTHON_VERSION_RESOLVED" + if [ "$PYTHON_RUNTIME_ID" != "cpython-3.13.1-freethreaded" ]; then + echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID" exit 1 fi shell: bash env: - PYTHON_VERSION_RESOLVED: ${{ steps.setup-uv.outputs.python-version-resolved }} + PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }} test-activate-environment-no-project: runs-on: ubuntu-latest diff --git a/README.md b/README.md index bada3fd..873afe4 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,9 @@ 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-runtime-id`: An opaque identifier for the activated venv's Python runtime, including + implementation, full version, and free-threaded build information. Useful as a cache-key component. + Empty when `activate-environment` is false or the runtime 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-runtime.test.ts b/__tests__/utils/python-runtime.test.ts new file mode 100644 index 0000000..7981fa6 --- /dev/null +++ b/__tests__/utils/python-runtime.test.ts @@ -0,0 +1,210 @@ +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 { getPythonRuntimeId } = await import("../../src/utils/python-runtime"); + +function mockRuntime(overrides: Record = {}) { + mockGetExecOutput.mockResolvedValue({ + exitCode: 0, + stderr: "", + stdout: `${JSON.stringify({ + freethreaded: false, + implementation: "cpython", + implementationVersion: [3, 13, 1, "final", 0], + pythonVersion: "3.13.1", + ...overrides, + })}\r\n`, + }); +} + +beforeEach(() => { + mockGetExecOutput.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" }); + + expect(await getPythonRuntimeId(inputs)).toBe(""); + expect(mockGetExecOutput).not.toHaveBeenCalled(); + }); + + 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), + ); + }, + ); +}); diff --git a/__tests__/utils/python-version.test.ts b/__tests__/utils/python-version.test.ts deleted file mode 100644 index b8d7fc4..0000000 --- a/__tests__/utils/python-version.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 c4a6c5f..0be5368 100644 --- a/action-types.yml +++ b/action-types.yml @@ -79,7 +79,7 @@ outputs: type: string python-version: type: string - python-version-resolved: + python-runtime-id: type: string python-cache-hit: type: boolean diff --git a/action.yml b/action.yml index 0ac0988..905ddd5 100644 --- a/action.yml +++ b/action.yml @@ -107,8 +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-runtime-id: + description: "An opaque identifier for the activated venv's Python runtime, including implementation, full version, and free-threaded build information. Empty when activate-environment is false or the runtime 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 bfd1db1..427988f 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -102101,9 +102101,41 @@ function getResolutionStrategy() { ); } -// src/utils/python-version.ts +// src/utils/python-runtime.ts var import_node_path2 = require("node:path"); -async function getResolvedPythonVersion(inputs) { +var PYTHON_RUNTIME_QUERY = ` +import json +import platform +import sys +import sysconfig + +print(json.dumps({ + "implementation": sys.implementation.name, + "implementationVersion": list(sys.implementation.version), + "pythonVersion": platform.python_version(), + "freethreaded": sysconfig.get_config_var("Py_GIL_DISABLED") == 1, +})) +`; +function formatRuntimeId(runtime) { + if (typeof runtime.implementation !== "string" || runtime.implementation === "" || typeof runtime.pythonVersion !== "string" || runtime.pythonVersion === "" || typeof runtime.freethreaded !== "boolean") { + throw new Error("Invalid Python runtime metadata"); + } + let id = `cpython-${runtime.pythonVersion}`; + if (runtime.implementation !== "cpython") { + const [major2, minor2, micro, releaseLevel, serial] = runtime.implementationVersion; + const suffixes = { alpha: "a", beta: "b", candidate: "rc", final: "" }; + const suffix = suffixes[releaseLevel]; + if (suffix === void 0 || ![major2, minor2, micro, serial].every( + (part) => Number.isInteger(part) && part >= 0 + )) { + throw new Error("Invalid Python implementation version"); + } + const implementationVersion = `${major2}.${minor2}.${micro}${suffix}${suffix ? serial : ""}`; + id = `${runtime.implementation}-${implementationVersion}-python-${runtime.pythonVersion}`; + } + return runtime.freethreaded ? `${id}-freethreaded` : id; +} +async function getPythonRuntimeId(inputs) { if (!inputs.activateEnvironment) { return ""; } @@ -102111,13 +102143,13 @@ async function getResolvedPythonVersion(inputs) { try { const { stdout } = await getExecOutput( `"${pythonPath.replace(/"/g, '\\"')}"`, - ["-I", "-c", "import platform; print(platform.python_version())"], + ["-I", "-c", PYTHON_RUNTIME_QUERY], { silent: !isDebug() } ); - return stdout.trim(); + return formatRuntimeId(JSON.parse(stdout)); } catch (error2) { debug( - `Failed to get the activated environment's Python version. Error: ${error2 instanceof Error ? error2.message : String(error2)}` + `Failed to identify the activated environment's Python runtime. Error: ${error2 instanceof Error ? error2.message : String(error2)}` ); return ""; } @@ -102193,10 +102225,7 @@ 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) - ); + setOutput("python-runtime-id", await getPythonRuntimeId(inputs)); if (inputs.enableCache) { await restoreCache2(inputs, detectedPythonVersion); } diff --git a/docs/environment-and-tools.md b/docs/environment-and-tools.md index ea49560..a0c544a 100644 --- a/docs/environment-and-tools.md +++ b/docs/environment-and-tools.md @@ -17,18 +17,35 @@ 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). +With `activate-environment: true`, the `python-runtime-id` output identifies the +venv's Python runtime using its implementation, full Python version (including +prerelease numbers), and whether the interpreter was built for free threading. +For implementations other than CPython, it also includes the implementation's own +version, so upgrading PyPy changes the identifier even if its Python version stays +the same. -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. +Example identifiers: + +| Runtime | `python-runtime-id` | +| --- | --- | +| CPython RC1 | `cpython-3.15.0rc1` | +| Free-threaded CPython RC1 | `cpython-3.15.0rc1-freethreaded` | +| Free-threaded CPython RC2 | `cpython-3.15.0rc2-freethreaded` | +| PyPy | `pypy-7.3.23-python-3.11.15` | + +This is an opaque identifier in a setup-uv-defined format, not a Python version +specifier. Use the whole value as a cache-key component rather than parsing it. +Combine it with the platform and dependency information relevant to your cache, +for example (with `id: setup-uv` on the setup step): + +```yaml +key: build-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-uv.outputs.python-runtime-id }}-${{ hashFiles('uv.lock') }} +``` + +The free-threaded marker describes the interpreter's build even when the GIL is +enabled at runtime. The output is empty when `activate-environment` is false or the +runtime 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: diff --git a/src/setup-uv.ts b/src/setup-uv.ts index 74edbea..f83087f 100644 --- a/src/setup-uv.ts +++ b/src/setup-uv.ts @@ -16,7 +16,7 @@ import { getPlatform, type Platform, } from "./utils/platforms"; -import { getResolvedPythonVersion } from "./utils/python-version"; +import { getPythonRuntimeId } from "./utils/python-runtime"; import { resolveUvVersion } from "./version/resolve"; const sourceDir = __dirname; @@ -102,10 +102,7 @@ async function run(): Promise { const detectedPythonVersion = await getPythonVersion(inputs); core.setOutput("python-version", detectedPythonVersion); - core.setOutput( - "python-version-resolved", - await getResolvedPythonVersion(inputs), - ); + core.setOutput("python-runtime-id", await getPythonRuntimeId(inputs)); if (inputs.enableCache) { await restoreCache(inputs, detectedPythonVersion); diff --git a/src/utils/python-runtime.ts b/src/utils/python-runtime.ts new file mode 100644 index 0000000..f183515 --- /dev/null +++ b/src/utils/python-runtime.ts @@ -0,0 +1,85 @@ +import { join } from "node:path"; +import * as core from "@actions/core"; +import * as exec from "@actions/exec"; +import type { SetupInputs } from "./inputs"; + +const PYTHON_RUNTIME_QUERY = ` +import json +import platform +import sys +import sysconfig + +print(json.dumps({ + "implementation": sys.implementation.name, + "implementationVersion": list(sys.implementation.version), + "pythonVersion": platform.python_version(), + "freethreaded": sysconfig.get_config_var("Py_GIL_DISABLED") == 1, +})) +`; + +type ReleaseLevel = "alpha" | "beta" | "candidate" | "final"; + +interface PythonRuntime { + implementation: string; + implementationVersion: [number, number, number, ReleaseLevel, number]; + pythonVersion: string; + freethreaded: boolean; +} + +function formatRuntimeId(runtime: PythonRuntime): string { + if ( + typeof runtime.implementation !== "string" || + runtime.implementation === "" || + typeof runtime.pythonVersion !== "string" || + runtime.pythonVersion === "" || + typeof runtime.freethreaded !== "boolean" + ) { + throw new Error("Invalid Python runtime metadata"); + } + + let id = `cpython-${runtime.pythonVersion}`; + if (runtime.implementation !== "cpython") { + const [major, minor, micro, releaseLevel, serial] = + runtime.implementationVersion; + const suffixes = { alpha: "a", beta: "b", candidate: "rc", final: "" }; + const suffix = suffixes[releaseLevel]; + if ( + suffix === undefined || + ![major, minor, micro, serial].every( + (part) => Number.isInteger(part) && part >= 0, + ) + ) { + throw new Error("Invalid Python implementation version"); + } + const implementationVersion = `${major}.${minor}.${micro}${suffix}${suffix ? serial : ""}`; + id = `${runtime.implementation}-${implementationVersion}-python-${runtime.pythonVersion}`; + } + + return runtime.freethreaded ? `${id}-freethreaded` : id; +} + +export async function getPythonRuntimeId(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", PYTHON_RUNTIME_QUERY], + { silent: !core.isDebug() }, + ); + return formatRuntimeId(JSON.parse(stdout)); + } catch (error) { + core.debug( + `Failed to identify the activated environment's Python runtime. Error: ${error instanceof Error ? error.message : String(error)}`, + ); + return ""; + } +} diff --git a/src/utils/python-version.ts b/src/utils/python-version.ts deleted file mode 100644 index ff06fb2..0000000 --- a/src/utils/python-version.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 ""; - } -}