From 22b0154febcd3bc1059481a5ecda8953c6ea5591 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 3 Sep 2026 16:15:42 -0400 Subject: [PATCH] Use uv Python keys for runtime IDs --- .github/workflows/test.yml | 32 +++----- README.md | 4 +- __tests__/utils/python-runtime.test.ts | 108 +++++++++---------------- action.yml | 2 +- dist/setup/index.cjs | 51 ++++-------- docs/environment-and-tools.md | 2 +- src/utils/python-runtime.ts | 83 +++++-------------- 7 files changed, 88 insertions(+), 194 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4a4ab8..bbfc0e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -386,29 +386,21 @@ jobs: shell: bash - name: Verify Python version outputs and cache key run: | - expected=$(python -I - <<'PY' + expected_prefix=$(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()}" + runtime = f"{sys.implementation.name}-{platform.python_version()}" if sysconfig.get_config_var("Py_GIL_DISABLED") == 1: - runtime += "-freethreaded" - print(runtime) + runtime += "+freethreaded" + print(f"{runtime}-") PY ) - if [ "$PYTHON_RUNTIME_ID" != "$expected" ]; then - echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID (expected $expected)" - exit 1 - fi + case "$PYTHON_RUNTIME_ID" in + "$expected_prefix"*) ;; + *) echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID (expected prefix $expected_prefix)"; exit 1 ;; + esac if [ "$PYTHON_VERSION" != "$PYTHON_REQUEST" ]; then echo "Wrong requested Python version: $PYTHON_VERSION" exit 1 @@ -489,10 +481,10 @@ jobs: shell: bash - name: Verify Python runtime ID from custom venv run: | - if [ "$PYTHON_RUNTIME_ID" != "cpython-3.13.1-freethreaded" ]; then - echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID" - exit 1 - fi + case "$PYTHON_RUNTIME_ID" in + cpython-3.13.1+freethreaded-*) ;; + *) echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID"; exit 1 ;; + esac shell: bash env: PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }} diff --git a/README.md b/README.md index c27a954..226ab29 100644 --- a/README.md +++ b/README.md @@ -138,8 +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-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. +- `python-runtime-id`: An opaque identifier reported by uv for the activated venv's Python runtime, including + implementation, full Python version, and free-threaded build information. Useful as a cache-key component. Empty when `activate-environment` is false. The action fails if the activated runtime cannot be determined. - `python-cache-hit`: A boolean value to indicate the Python cache entry was found. diff --git a/__tests__/utils/python-runtime.test.ts b/__tests__/utils/python-runtime.test.ts index 3e882fd..2f00b12 100644 --- a/__tests__/utils/python-runtime.test.ts +++ b/__tests__/utils/python-runtime.test.ts @@ -1,13 +1,11 @@ -import { join } from "node:path"; import { promisify } from "node:util"; -import { afterEach, beforeEach, expect, it, jest } from "@jest/globals"; +import { beforeEach, expect, it, jest } from "@jest/globals"; import { createSetupInputs } from "../helpers/setup-inputs"; const mockExecFile = jest.fn< (...args: unknown[]) => Promise<{ stdout: string; stderr: string }> >(); -const originalPlatform = process.platform; const inputs = createSetupInputs({ activateEnvironment: true, pythonVersion: "3.15t", @@ -20,28 +18,15 @@ jest.unstable_mockModule("node:child_process", () => ({ const { getPythonRuntimeId } = await import("../../src/utils/python-runtime"); -function mockRuntime(overrides: Record = {}) { - mockExecFile.mockResolvedValue({ - stderr: "", - stdout: `${JSON.stringify({ - freethreaded: false, - implementation: "cpython", - implementationVersion: [3, 13, 1, "final", 0], - pythonVersion: "3.13.1", - ...overrides, - })}\r\n`, - }); -} - beforeEach(() => { mockExecFile.mockReset(); - mockRuntime(); -}); -afterEach(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); + mockExecFile.mockResolvedValue({ + stderr: "", + stdout: '[{"key":"cpython-3.13.1-linux-x86_64-gnu"}]\r\n', + }); }); -it("does not query Python without environment activation", async () => { +it("does not query uv without environment activation", async () => { expect( await getPythonRuntimeId({ ...inputs, activateEnvironment: false }), ).toBe(""); @@ -49,62 +34,47 @@ it("does not query Python without environment activation", async () => { }); 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"], -])("formats %s implementation version %j", async (name, version, expected) => { - mockRuntime({ - implementation: name, - implementationVersion: version, - pythonVersion: "3.11.15", + "cpython-3.13.1-linux-x86_64-gnu", + "cpython-3.15.0rc1+freethreaded-macos-aarch64-none", + "cpython-3.15.0rc2+freethreaded-windows-x86_64-none", + "pypy-3.11.15-linux-x86_64-gnu", +])("returns uv's opaque runtime key unchanged: %s", async (key) => { + mockExecFile.mockResolvedValue({ + stderr: "", + stdout: `${JSON.stringify([{ key }])}\r\n`, }); - expect(await getPythonRuntimeId(inputs)).toBe(`${expected}-python-3.11.15`); + expect(await getPythonRuntimeId(inputs)).toBe(key); }); -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(['/runner temp/a "quoted" venv', "C:\\runner temp\\custom venv"])( + "queries the exact venv directory: %s", + async (venvPath) => { + await getPythonRuntimeId({ ...inputs, venvPath }); + expect(mockExecFile).toHaveBeenCalledWith( + "uv", + [ + "python", + "list", + venvPath, + "--only-installed", + "--output-format", + "json", + ], + { encoding: "utf8" }, + ); + }, +); it.each([ - new Error("interpreter missing"), - "", + new Error("uv failed"), "not JSON", "null", "{}", - JSON.stringify({ - freethreaded: false, - implementation: "pypy", - implementationVersion: [7, 3, 24, "unknown", 0], - pythonVersion: "3.11.15", - }), -])("rejects interpreter failure or invalid metadata: %s", async (result) => { + "[]", + '[{"key":""}]', + '[{"key":123}]', + '[{"key":"first"},{"key":"second"}]', +])("rejects uv failure or invalid results: %s", async (result) => { if (result instanceof Error) { mockExecFile.mockRejectedValue(result); } else { diff --git a/action.yml b/action.yml index 22729dc..5a8216d 100644 --- a/action.yml +++ b/action.yml @@ -108,7 +108,7 @@ outputs: python-version: description: "The Python version that was set." 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. The action fails if the activated runtime cannot be determined." + description: "An opaque identifier reported by uv for the activated venv's Python runtime, including implementation, full Python version, and free-threaded build information. Empty when activate-environment is false. The action fails if the activated 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 73b6d57..3074bc8 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -102068,53 +102068,30 @@ 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 -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 ""; } - 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 execFileAsync( - pythonPath, - ["-I", "-c", PYTHON_RUNTIME_QUERY], + "uv", + [ + "python", + "list", + inputs.venvPath, + "--only-installed", + "--output-format", + "json" + ], { encoding: "utf8" } ); - return formatRuntimeId(JSON.parse(stdout)); + const pythons = JSON.parse(stdout); + if (!Array.isArray(pythons) || pythons.length !== 1 || typeof pythons[0]?.key !== "string" || pythons[0].key === "") { + throw new Error("Expected one installed Python with a runtime key"); + } + return pythons[0].key; } catch (error2) { throw new Error( `Failed to identify the activated environment's Python runtime: ${error2 instanceof Error ? error2.message : String(error2)}`, diff --git a/docs/environment-and-tools.md b/docs/environment-and-tools.md index da9c112..5b3c07b 100644 --- a/docs/environment-and-tools.md +++ b/docs/environment-and-tools.md @@ -18,7 +18,7 @@ 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-runtime-id` output identifies the -venv's Python runtime. This is an opaque identifier that users of the action +venv's Python runtime as reported by uv. This is an opaque identifier that users of the action can use as a cache key if necessary; users should not assume anything about the stability or structure of the identifier itself. diff --git a/src/utils/python-runtime.ts b/src/utils/python-runtime.ts index c10153e..e1bdbe5 100644 --- a/src/utils/python-runtime.ts +++ b/src/utils/python-runtime.ts @@ -1,82 +1,37 @@ import { execFile } from "node:child_process"; -import { join } from "node:path"; import { promisify } from "node:util"; import type { SetupInputs } from "./inputs"; const execFileAsync = promisify(execFile); -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 { const { stdout } = await execFileAsync( - pythonPath, - ["-I", "-c", PYTHON_RUNTIME_QUERY], + "uv", + [ + "python", + "list", + inputs.venvPath, + "--only-installed", + "--output-format", + "json", + ], { encoding: "utf8" }, ); - return formatRuntimeId(JSON.parse(stdout)); + const pythons = JSON.parse(stdout); + if ( + !Array.isArray(pythons) || + pythons.length !== 1 || + typeof pythons[0]?.key !== "string" || + pythons[0].key === "" + ) { + throw new Error("Expected one installed Python with a runtime key"); + } + return pythons[0].key; } catch (error) { throw new Error( `Failed to identify the activated environment's Python runtime: ${error instanceof Error ? error.message : String(error)}`,