Use uv Python keys for runtime IDs

This commit is contained in:
William Woodruff
2026-09-03 16:15:42 -04:00
parent 358b824344
commit 22b0154feb
7 changed files with 88 additions and 194 deletions
+12 -20
View File
@@ -386,29 +386,21 @@ jobs:
shell: bash shell: bash
- name: Verify Python version outputs and cache key - name: Verify Python version outputs and cache key
run: | run: |
expected=$(python -I - <<'PY' expected_prefix=$(python -I - <<'PY'
import platform import platform
import sys import sys
import sysconfig import sysconfig
if sys.implementation.name == "cpython": runtime = f"{sys.implementation.name}-{platform.python_version()}"
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: if sysconfig.get_config_var("Py_GIL_DISABLED") == 1:
runtime += "-freethreaded" runtime += "+freethreaded"
print(runtime) print(f"{runtime}-")
PY PY
) )
if [ "$PYTHON_RUNTIME_ID" != "$expected" ]; then case "$PYTHON_RUNTIME_ID" in
echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID (expected $expected)" "$expected_prefix"*) ;;
exit 1 *) echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID (expected prefix $expected_prefix)"; exit 1 ;;
fi esac
if [ "$PYTHON_VERSION" != "$PYTHON_REQUEST" ]; then if [ "$PYTHON_VERSION" != "$PYTHON_REQUEST" ]; then
echo "Wrong requested Python version: $PYTHON_VERSION" echo "Wrong requested Python version: $PYTHON_VERSION"
exit 1 exit 1
@@ -489,10 +481,10 @@ jobs:
shell: bash shell: bash
- name: Verify Python runtime ID from custom venv - name: Verify Python runtime ID from custom venv
run: | run: |
if [ "$PYTHON_RUNTIME_ID" != "cpython-3.13.1-freethreaded" ]; then case "$PYTHON_RUNTIME_ID" in
echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID" cpython-3.13.1+freethreaded-*) ;;
exit 1 *) echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID"; exit 1 ;;
fi esac
shell: bash shell: bash
env: env:
PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }} PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }}
+2 -2
View File
@@ -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. - `cache-hit`: A boolean value to indicate a cache entry was found.
- `venv`: Path to the activated venv if activate-environment is true. - `venv`: Path to the activated venv if activate-environment is true.
- `python-version`: The Python version that was set. - `python-version`: The Python version that was set.
- `python-runtime-id`: An opaque identifier for the activated venv's Python runtime, including - `python-runtime-id`: An opaque identifier reported by uv for the activated venv's Python runtime, including
implementation, full version, and free-threaded build information. Useful as a cache-key component. 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. 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. - `python-cache-hit`: A boolean value to indicate the Python cache entry was found.
+35 -65
View File
@@ -1,13 +1,11 @@
import { join } from "node:path";
import { promisify } from "node:util"; 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"; import { createSetupInputs } from "../helpers/setup-inputs";
const mockExecFile = const mockExecFile =
jest.fn< jest.fn<
(...args: unknown[]) => Promise<{ stdout: string; stderr: string }> (...args: unknown[]) => Promise<{ stdout: string; stderr: string }>
>(); >();
const originalPlatform = process.platform;
const inputs = createSetupInputs({ const inputs = createSetupInputs({
activateEnvironment: true, activateEnvironment: true,
pythonVersion: "3.15t", pythonVersion: "3.15t",
@@ -20,28 +18,15 @@ jest.unstable_mockModule("node:child_process", () => ({
const { getPythonRuntimeId } = await import("../../src/utils/python-runtime"); const { getPythonRuntimeId } = await import("../../src/utils/python-runtime");
function mockRuntime(overrides: Record<string, unknown> = {}) {
mockExecFile.mockResolvedValue({
stderr: "",
stdout: `${JSON.stringify({
freethreaded: false,
implementation: "cpython",
implementationVersion: [3, 13, 1, "final", 0],
pythonVersion: "3.13.1",
...overrides,
})}\r\n`,
});
}
beforeEach(() => { beforeEach(() => {
mockExecFile.mockReset(); mockExecFile.mockReset();
mockRuntime(); mockExecFile.mockResolvedValue({
stderr: "",
stdout: '[{"key":"cpython-3.13.1-linux-x86_64-gnu"}]\r\n',
}); });
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform });
}); });
it("does not query Python without environment activation", async () => { it("does not query uv without environment activation", async () => {
expect( expect(
await getPythonRuntimeId({ ...inputs, activateEnvironment: false }), await getPythonRuntimeId({ ...inputs, activateEnvironment: false }),
).toBe(""); ).toBe("");
@@ -49,62 +34,47 @@ it("does not query Python without environment activation", async () => {
}); });
it.each([ it.each([
["3.13.1", false, "cpython-3.13.1"], "cpython-3.13.1-linux-x86_64-gnu",
["3.15.0a1", false, "cpython-3.15.0a1"], "cpython-3.15.0rc1+freethreaded-macos-aarch64-none",
["3.15.0b2", false, "cpython-3.15.0b2"], "cpython-3.15.0rc2+freethreaded-windows-x86_64-none",
["3.15.0rc1", false, "cpython-3.15.0rc1"], "pypy-3.11.15-linux-x86_64-gnu",
["3.15.0rc1", true, "cpython-3.15.0rc1-freethreaded"], ])("returns uv's opaque runtime key unchanged: %s", async (key) => {
["3.15.0rc2", true, "cpython-3.15.0rc2-freethreaded"], mockExecFile.mockResolvedValue({
["3.15.0", true, "cpython-3.15.0-freethreaded"], stderr: "",
])("formats CPython %s, free-threading=%s", async (version, free, expected) => { stdout: `${JSON.stringify([{ key }])}\r\n`,
mockRuntime({ freethreaded: free, pythonVersion: version }); });
expect(await getPythonRuntimeId(inputs)).toBe(expected); expect(await getPythonRuntimeId(inputs)).toBe(key);
}); });
it.each([ it.each(['/runner temp/a "quoted" venv', "C:\\runner temp\\custom venv"])(
["pypy", [7, 3, 23, "final", 0], "pypy-7.3.23"], "queries the exact venv directory: %s",
["pypy", [7, 3, 24, "final", 0], "pypy-7.3.24"], async (venvPath) => {
["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",
});
expect(await getPythonRuntimeId(inputs)).toBe(`${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 }); await getPythonRuntimeId({ ...inputs, venvPath });
expect(mockExecFile).toHaveBeenCalledWith( expect(mockExecFile).toHaveBeenCalledWith(
join(venvPath, exe), "uv",
["-I", "-c", expect.any(String)], [
"python",
"list",
venvPath,
"--only-installed",
"--output-format",
"json",
],
{ encoding: "utf8" }, { encoding: "utf8" },
); );
}); },
);
it.each([ it.each([
new Error("interpreter missing"), new Error("uv failed"),
"",
"not JSON", "not JSON",
"null", "null",
"{}", "{}",
JSON.stringify({ "[]",
freethreaded: false, '[{"key":""}]',
implementation: "pypy", '[{"key":123}]',
implementationVersion: [7, 3, 24, "unknown", 0], '[{"key":"first"},{"key":"second"}]',
pythonVersion: "3.11.15", ])("rejects uv failure or invalid results: %s", async (result) => {
}),
])("rejects interpreter failure or invalid metadata: %s", async (result) => {
if (result instanceof Error) { if (result instanceof Error) {
mockExecFile.mockRejectedValue(result); mockExecFile.mockRejectedValue(result);
} else { } else {
+1 -1
View File
@@ -108,7 +108,7 @@ outputs:
python-version: python-version:
description: "The Python version that was set." description: "The Python version that was set."
python-runtime-id: 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: python-cache-hit:
description: "A boolean value to indicate the Python cache entry was found" description: "A boolean value to indicate the Python cache entry was found"
runs: runs:
Generated Vendored
+14 -37
View File
@@ -102068,53 +102068,30 @@ function getResolutionStrategy() {
// src/utils/python-runtime.ts // src/utils/python-runtime.ts
var import_node_child_process = require("node:child_process"); var import_node_child_process = require("node:child_process");
var import_node_path2 = require("node:path");
var import_node_util4 = require("node:util"); var import_node_util4 = require("node:util");
var execFileAsync = (0, import_node_util4.promisify)(import_node_child_process.execFile); 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) { async function getPythonRuntimeId(inputs) {
if (!inputs.activateEnvironment) { if (!inputs.activateEnvironment) {
return ""; 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 { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
pythonPath, "uv",
["-I", "-c", PYTHON_RUNTIME_QUERY], [
"python",
"list",
inputs.venvPath,
"--only-installed",
"--output-format",
"json"
],
{ encoding: "utf8" } { 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) { } catch (error2) {
throw new Error( throw new Error(
`Failed to identify the activated environment's Python runtime: ${error2 instanceof Error ? error2.message : String(error2)}`, `Failed to identify the activated environment's Python runtime: ${error2 instanceof Error ? error2.message : String(error2)}`,
+1 -1
View File
@@ -18,7 +18,7 @@ This allows directly using it in later steps:
By default, the venv is created at `.venv` inside the `working-directory`. By default, the venv is created at `.venv` inside the `working-directory`.
With `activate-environment: true`, the `python-runtime-id` output identifies the 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 can use as a cache key if necessary; users should not assume anything about
the stability or structure of the identifier itself. the stability or structure of the identifier itself.
+19 -64
View File
@@ -1,82 +1,37 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { SetupInputs } from "./inputs"; import type { SetupInputs } from "./inputs";
const execFileAsync = promisify(execFile); 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<string> { export async function getPythonRuntimeId(inputs: SetupInputs): Promise<string> {
if (!inputs.activateEnvironment) { if (!inputs.activateEnvironment) {
return ""; return "";
} }
const pythonPath =
process.platform === "win32"
? join(inputs.venvPath, "Scripts", "python.exe")
: join(inputs.venvPath, "bin", "python");
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
pythonPath, "uv",
["-I", "-c", PYTHON_RUNTIME_QUERY], [
"python",
"list",
inputs.venvPath,
"--only-installed",
"--output-format",
"json",
],
{ encoding: "utf8" }, { 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) { } catch (error) {
throw new Error( throw new Error(
`Failed to identify the activated environment's Python runtime: ${error instanceof Error ? error.message : String(error)}`, `Failed to identify the activated environment's Python runtime: ${error instanceof Error ? error.message : String(error)}`,