From f634bf473ad85bf3e23a613f52c5fa9f363874fc Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 9 Sep 2026 12:45:52 -0400 Subject: [PATCH] Expose a Python "identity" output (#1036) https://github.com/pyca/cryptography/pull/15572#discussion_r3913508686 has the context for this: TL;DR our current `python-version` output mirrors the "request" version exactly, which means that it's insufficient for any downstream that needs to manage its own cache keys (since caches shouldn't be shared across release candidates, but the `uv python` request version doesn't include RC numbers). The first commit here was my attempt to fix this by exposing the runtime Python version, but this too is imprecise: the runtime version doesn't indicate the interpreter variant (e.g. freethreading), which is also important to capture in the cache identity. My solution here is to expose `python-runtime-id`, which is just the `key` of the active Python version from `uv python list --output-format=json`. --------- Signed-off-by: William Woodruff --- .github/workflows/test.yml | 14 +++++ README.md | 1 + __tests__/utils/python-runtime.test.ts | 86 ++++++++++++++++++++++++++ action-types.yml | 2 + action.yml | 2 + dist/setup/index.cjs | 51 ++++++++++++--- docs/environment-and-tools.md | 12 ++++ src/setup-uv.ts | 2 + src/utils/python-runtime.ts | 43 +++++++++++++ 9 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 __tests__/utils/python-runtime.test.ts create mode 100644 src/utils/python-runtime.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2221d44..e05836f 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_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_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }} - run: uv sync working-directory: __tests__/fixtures/uv-project @@ -439,6 +444,15 @@ jobs: raise SystemExit(f"Python is not running from custom venv: {sys.executable}") PY shell: bash + - name: Verify Python runtime ID from custom venv + run: | + 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 }} test-activate-environment-no-project: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 260224d..1851422 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ 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 reported by uv for the activated venv's Python runtime. Empty when `activate-environment` is false. - `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..2f00b12 --- /dev/null +++ b/__tests__/utils/python-runtime.test.ts @@ -0,0 +1,86 @@ +import { promisify } from "node:util"; +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 inputs = createSetupInputs({ + activateEnvironment: true, + pythonVersion: "3.15t", +}); + +jest.unstable_mockModule("node:child_process", () => ({ + // execFile's custom promisifier returns both stdout and stderr. + execFile: Object.assign(mockExecFile, { [promisify.custom]: mockExecFile }), +})); + +const { getPythonRuntimeId } = await import("../../src/utils/python-runtime"); + +beforeEach(() => { + mockExecFile.mockReset(); + mockExecFile.mockResolvedValue({ + stderr: "", + stdout: '[{"key":"cpython-3.13.1-linux-x86_64-gnu"}]\r\n', + }); +}); + +it("does not query uv without environment activation", async () => { + expect( + await getPythonRuntimeId({ ...inputs, activateEnvironment: false }), + ).toBe(""); + expect(mockExecFile).not.toHaveBeenCalled(); +}); + +it.each([ + "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(key); +}); + +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("uv failed"), + "not JSON", + "null", + "{}", + "[]", + '[{"key":""}]', + '[{"key":123}]', + '[{"key":"first"},{"key":"second"}]', +])("rejects uv failure or invalid results: %s", async (result) => { + if (result instanceof Error) { + mockExecFile.mockRejectedValue(result); + } else { + mockExecFile.mockResolvedValue({ stderr: "", stdout: result }); + } + await expect(getPythonRuntimeId(inputs)).rejects.toThrow( + "Failed to identify the activated environment's Python runtime:", + ); +}); diff --git a/action-types.yml b/action-types.yml index 20c6ec4..0be5368 100644 --- a/action-types.yml +++ b/action-types.yml @@ -79,5 +79,7 @@ outputs: type: string python-version: type: string + python-runtime-id: + type: string python-cache-hit: type: boolean diff --git a/action.yml b/action.yml index 50b05ec..2a51899 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-runtime-id: + description: "An opaque identifier reported by uv for the activated venv's Python runtime. Empty when activate-environment is false." 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 0946f4f..4022d81 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -10833,7 +10833,7 @@ var require_mock_interceptor = __commonJS({ var require_mock_client = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-client.js"(exports2, module2) { "use strict"; - var { promisify: promisify5 } = require("node:util"); + var { promisify: promisify6 } = require("node:util"); var Client = require_client(); var { buildMockDispatch } = require_mock_utils(); var { @@ -10873,7 +10873,7 @@ var require_mock_client = __commonJS({ return new MockInterceptor(opts, this[kDispatches]); } async [kClose]() { - await promisify5(this[kOriginalClose])(); + await promisify6(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -10886,7 +10886,7 @@ var require_mock_client = __commonJS({ var require_mock_pool = __commonJS({ "node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) { "use strict"; - var { promisify: promisify5 } = require("node:util"); + var { promisify: promisify6 } = require("node:util"); var Pool = require_pool(); var { buildMockDispatch } = require_mock_utils(); var { @@ -10926,7 +10926,7 @@ var require_mock_pool = __commonJS({ return new MockInterceptor(opts, this[kDispatches]); } async [kClose]() { - await promisify5(this[kOriginalClose])(); + await promisify6(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -43099,7 +43099,7 @@ var require_mock_interceptor2 = __commonJS({ var require_mock_client2 = __commonJS({ "node_modules/undici/lib/mock/mock-client.js"(exports2, module2) { "use strict"; - var { promisify: promisify5 } = require("node:util"); + var { promisify: promisify6 } = require("node:util"); var Client = require_client2(); var { buildMockDispatch } = require_mock_utils2(); var { @@ -43147,7 +43147,7 @@ var require_mock_client2 = __commonJS({ this[kDispatches] = []; } async [kClose]() { - await promisify5(this[kOriginalClose])(); + await promisify6(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -43360,7 +43360,7 @@ var require_mock_call_history = __commonJS({ var require_mock_pool2 = __commonJS({ "node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) { "use strict"; - var { promisify: promisify5 } = require("node:util"); + var { promisify: promisify6 } = require("node:util"); var Pool = require_pool2(); var { buildMockDispatch } = require_mock_utils2(); var { @@ -43408,7 +43408,7 @@ var require_mock_pool2 = __commonJS({ this[kDispatches] = []; } async [kClose]() { - await promisify5(this[kOriginalClose])(); + await promisify6(this[kOriginalClose])(); this[kConnected] = 0; this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); } @@ -102102,6 +102102,40 @@ function getResolutionStrategy() { ); } +// src/utils/python-runtime.ts +var import_node_child_process = require("node:child_process"); +var import_node_util4 = require("node:util"); +var execFileAsync = (0, import_node_util4.promisify)(import_node_child_process.execFile); +async function getPythonRuntimeId(inputs) { + if (!inputs.activateEnvironment) { + return ""; + } + try { + const { stdout } = await execFileAsync( + "uv", + [ + "python", + "list", + inputs.venvPath, + "--only-installed", + "--output-format", + "json" + ], + { encoding: "utf8" } + ); + 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)}`, + { cause: error2 } + ); + } +} + // src/setup-uv.ts var sourceDir = __dirname; function formatUnexpectedFailure(error2) { @@ -102172,6 +102206,7 @@ async function run() { info2(`Successfully installed uv version ${setupResult.version}`); const detectedPythonVersion = await getPythonVersion2(inputs); setOutput("python-version", detectedPythonVersion); + 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 869d855..c98d356 100644 --- a/docs/environment-and-tools.md +++ b/docs/environment-and-tools.md @@ -17,6 +17,18 @@ 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 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. + +For example, you can combine it with the platform and dependency information relevant to +your cache 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') }} +``` + 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..f83087f 100644 --- a/src/setup-uv.ts +++ b/src/setup-uv.ts @@ -16,6 +16,7 @@ import { getPlatform, type Platform, } from "./utils/platforms"; +import { getPythonRuntimeId } from "./utils/python-runtime"; import { resolveUvVersion } from "./version/resolve"; const sourceDir = __dirname; @@ -101,6 +102,7 @@ async function run(): Promise { const detectedPythonVersion = await getPythonVersion(inputs); core.setOutput("python-version", detectedPythonVersion); + 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..c44b49e --- /dev/null +++ b/src/utils/python-runtime.ts @@ -0,0 +1,43 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { SetupInputs } from "./inputs"; + +const execFileAsync = promisify(execFile); + +export async function getPythonRuntimeId(inputs: SetupInputs): Promise { + if (!inputs.activateEnvironment) { + return ""; + } + + try { + // The venv path restricts results to this invocation's runtime, even if + // earlier setup-uv calls installed other Python versions in the same job. + const { stdout } = await execFileAsync( + "uv", + [ + "python", + "list", + inputs.venvPath, + "--only-installed", + "--output-format", + "json", + ], + { encoding: "utf8" }, + ); + 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)}`, + { cause: error }, + ); + } +}