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 }, + ); + } +}