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 <william@yossarian.net>
This commit is contained in:
William Woodruff
2026-09-09 12:45:52 -04:00
committed by GitHub
parent a6772c8f0a
commit f634bf473a
9 changed files with 205 additions and 8 deletions
+14
View File
@@ -347,9 +347,14 @@ jobs:
if [ "$PYTHON_VERSION" != "3.13.1t" ]; then if [ "$PYTHON_VERSION" != "3.13.1t" ]; then
exit 1 exit 1
fi fi
if [ -n "$PYTHON_RUNTIME_ID" ]; then
echo "python-runtime-id should be empty without environment activation"
exit 1
fi
shell: bash shell: bash
env: env:
PYTHON_VERSION: ${{ steps.setup-uv.outputs.python-version }} PYTHON_VERSION: ${{ steps.setup-uv.outputs.python-version }}
PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }}
- run: uv sync - run: uv sync
working-directory: __tests__/fixtures/uv-project working-directory: __tests__/fixtures/uv-project
@@ -439,6 +444,15 @@ jobs:
raise SystemExit(f"Python is not running from custom venv: {sys.executable}") raise SystemExit(f"Python is not running from custom venv: {sys.executable}")
PY PY
shell: bash 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: test-activate-environment-no-project:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1
View File
@@ -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. - `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 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-cache-hit`: A boolean value to indicate the Python cache entry was found.
### Python version ### Python version
+86
View File
@@ -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:",
);
});
+2
View File
@@ -79,5 +79,7 @@ outputs:
type: string type: string
python-version: python-version:
type: string type: string
python-runtime-id:
type: string
python-cache-hit: python-cache-hit:
type: boolean type: boolean
+2
View File
@@ -107,6 +107,8 @@ outputs:
description: "Path to the activated venv if activate-environment is true" description: "Path to the activated venv if activate-environment is true"
python-version: python-version:
description: "The Python version that was set." 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: 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
+43 -8
View File
@@ -10833,7 +10833,7 @@ var require_mock_interceptor = __commonJS({
var require_mock_client = __commonJS({ var require_mock_client = __commonJS({
"node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-client.js"(exports2, module2) { "node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-client.js"(exports2, module2) {
"use strict"; "use strict";
var { promisify: promisify5 } = require("node:util"); var { promisify: promisify6 } = require("node:util");
var Client = require_client(); var Client = require_client();
var { buildMockDispatch } = require_mock_utils(); var { buildMockDispatch } = require_mock_utils();
var { var {
@@ -10873,7 +10873,7 @@ var require_mock_client = __commonJS({
return new MockInterceptor(opts, this[kDispatches]); return new MockInterceptor(opts, this[kDispatches]);
} }
async [kClose]() { async [kClose]() {
await promisify5(this[kOriginalClose])(); await promisify6(this[kOriginalClose])();
this[kConnected] = 0; this[kConnected] = 0;
this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); this[kMockAgent][Symbols.kClients].delete(this[kOrigin]);
} }
@@ -10886,7 +10886,7 @@ var require_mock_client = __commonJS({
var require_mock_pool = __commonJS({ var require_mock_pool = __commonJS({
"node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) { "node_modules/@actions/http-client/node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) {
"use strict"; "use strict";
var { promisify: promisify5 } = require("node:util"); var { promisify: promisify6 } = require("node:util");
var Pool = require_pool(); var Pool = require_pool();
var { buildMockDispatch } = require_mock_utils(); var { buildMockDispatch } = require_mock_utils();
var { var {
@@ -10926,7 +10926,7 @@ var require_mock_pool = __commonJS({
return new MockInterceptor(opts, this[kDispatches]); return new MockInterceptor(opts, this[kDispatches]);
} }
async [kClose]() { async [kClose]() {
await promisify5(this[kOriginalClose])(); await promisify6(this[kOriginalClose])();
this[kConnected] = 0; this[kConnected] = 0;
this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); this[kMockAgent][Symbols.kClients].delete(this[kOrigin]);
} }
@@ -43099,7 +43099,7 @@ var require_mock_interceptor2 = __commonJS({
var require_mock_client2 = __commonJS({ var require_mock_client2 = __commonJS({
"node_modules/undici/lib/mock/mock-client.js"(exports2, module2) { "node_modules/undici/lib/mock/mock-client.js"(exports2, module2) {
"use strict"; "use strict";
var { promisify: promisify5 } = require("node:util"); var { promisify: promisify6 } = require("node:util");
var Client = require_client2(); var Client = require_client2();
var { buildMockDispatch } = require_mock_utils2(); var { buildMockDispatch } = require_mock_utils2();
var { var {
@@ -43147,7 +43147,7 @@ var require_mock_client2 = __commonJS({
this[kDispatches] = []; this[kDispatches] = [];
} }
async [kClose]() { async [kClose]() {
await promisify5(this[kOriginalClose])(); await promisify6(this[kOriginalClose])();
this[kConnected] = 0; this[kConnected] = 0;
this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); this[kMockAgent][Symbols.kClients].delete(this[kOrigin]);
} }
@@ -43360,7 +43360,7 @@ var require_mock_call_history = __commonJS({
var require_mock_pool2 = __commonJS({ var require_mock_pool2 = __commonJS({
"node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) { "node_modules/undici/lib/mock/mock-pool.js"(exports2, module2) {
"use strict"; "use strict";
var { promisify: promisify5 } = require("node:util"); var { promisify: promisify6 } = require("node:util");
var Pool = require_pool2(); var Pool = require_pool2();
var { buildMockDispatch } = require_mock_utils2(); var { buildMockDispatch } = require_mock_utils2();
var { var {
@@ -43408,7 +43408,7 @@ var require_mock_pool2 = __commonJS({
this[kDispatches] = []; this[kDispatches] = [];
} }
async [kClose]() { async [kClose]() {
await promisify5(this[kOriginalClose])(); await promisify6(this[kOriginalClose])();
this[kConnected] = 0; this[kConnected] = 0;
this[kMockAgent][Symbols.kClients].delete(this[kOrigin]); 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 // src/setup-uv.ts
var sourceDir = __dirname; var sourceDir = __dirname;
function formatUnexpectedFailure(error2) { function formatUnexpectedFailure(error2) {
@@ -102172,6 +102206,7 @@ async function run() {
info2(`Successfully installed uv version ${setupResult.version}`); info2(`Successfully installed uv version ${setupResult.version}`);
const detectedPythonVersion = await getPythonVersion2(inputs); const detectedPythonVersion = await getPythonVersion2(inputs);
setOutput("python-version", detectedPythonVersion); setOutput("python-version", detectedPythonVersion);
setOutput("python-runtime-id", await getPythonRuntimeId(inputs));
if (inputs.enableCache) { if (inputs.enableCache) {
await restoreCache2(inputs, detectedPythonVersion); await restoreCache2(inputs, detectedPythonVersion);
} }
+12
View File
@@ -17,6 +17,18 @@ 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
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: You can customize the venv location with `venv-path`, for example to place it in the runner temp directory:
```yaml ```yaml
+2
View File
@@ -16,6 +16,7 @@ import {
getPlatform, getPlatform,
type Platform, type Platform,
} from "./utils/platforms"; } from "./utils/platforms";
import { getPythonRuntimeId } from "./utils/python-runtime";
import { resolveUvVersion } from "./version/resolve"; import { resolveUvVersion } from "./version/resolve";
const sourceDir = __dirname; const sourceDir = __dirname;
@@ -101,6 +102,7 @@ async function run(): Promise<void> {
const detectedPythonVersion = await getPythonVersion(inputs); const detectedPythonVersion = await getPythonVersion(inputs);
core.setOutput("python-version", detectedPythonVersion); core.setOutput("python-version", detectedPythonVersion);
core.setOutput("python-runtime-id", await getPythonRuntimeId(inputs));
if (inputs.enableCache) { if (inputs.enableCache) {
await restoreCache(inputs, detectedPythonVersion); await restoreCache(inputs, detectedPythonVersion);
+43
View File
@@ -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<string> {
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 },
);
}
}