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
- 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 }}
+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.
- `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.
+39 -69
View File
@@ -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<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(() => {
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 {
+1 -1
View File
@@ -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:
Generated Vendored
+14 -37
View File
@@ -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)}`,
+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`.
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.
+19 -64
View File
@@ -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<string> {
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)}`,