Skip to content

Commit f634bf4

Browse files
authored
Expose a Python "identity" output (#1036)
pyca/cryptography#15572 (comment) 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>
1 parent a6772c8 commit f634bf4

9 files changed

Lines changed: 205 additions & 8 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,14 @@ jobs:
347347
if [ "$PYTHON_VERSION" != "3.13.1t" ]; then
348348
exit 1
349349
fi
350+
if [ -n "$PYTHON_RUNTIME_ID" ]; then
351+
echo "python-runtime-id should be empty without environment activation"
352+
exit 1
353+
fi
350354
shell: bash
351355
env:
352356
PYTHON_VERSION: ${{ steps.setup-uv.outputs.python-version }}
357+
PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }}
353358
- run: uv sync
354359
working-directory: __tests__/fixtures/uv-project
355360

@@ -439,6 +444,15 @@ jobs:
439444
raise SystemExit(f"Python is not running from custom venv: {sys.executable}")
440445
PY
441446
shell: bash
447+
- name: Verify Python runtime ID from custom venv
448+
run: |
449+
case "$PYTHON_RUNTIME_ID" in
450+
cpython-3.13.1+freethreaded-*) ;;
451+
*) echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID"; exit 1 ;;
452+
esac
453+
shell: bash
454+
env:
455+
PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }}
442456

443457
test-activate-environment-no-project:
444458
runs-on: ubuntu-latest

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed
138138
- `cache-hit`: A boolean value to indicate a cache entry was found.
139139
- `venv`: Path to the activated venv if activate-environment is true.
140140
- `python-version`: The Python version that was set.
141+
- `python-runtime-id`: An opaque identifier reported by uv for the activated venv's Python runtime. Empty when `activate-environment` is false.
141142
- `python-cache-hit`: A boolean value to indicate the Python cache entry was found.
142143

143144
### Python version
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { promisify } from "node:util";
2+
import { beforeEach, expect, it, jest } from "@jest/globals";
3+
import { createSetupInputs } from "../helpers/setup-inputs";
4+
5+
const mockExecFile =
6+
jest.fn<
7+
(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>
8+
>();
9+
const inputs = createSetupInputs({
10+
activateEnvironment: true,
11+
pythonVersion: "3.15t",
12+
});
13+
14+
jest.unstable_mockModule("node:child_process", () => ({
15+
// execFile's custom promisifier returns both stdout and stderr.
16+
execFile: Object.assign(mockExecFile, { [promisify.custom]: mockExecFile }),
17+
}));
18+
19+
const { getPythonRuntimeId } = await import("../../src/utils/python-runtime");
20+
21+
beforeEach(() => {
22+
mockExecFile.mockReset();
23+
mockExecFile.mockResolvedValue({
24+
stderr: "",
25+
stdout: '[{"key":"cpython-3.13.1-linux-x86_64-gnu"}]\r\n',
26+
});
27+
});
28+
29+
it("does not query uv without environment activation", async () => {
30+
expect(
31+
await getPythonRuntimeId({ ...inputs, activateEnvironment: false }),
32+
).toBe("");
33+
expect(mockExecFile).not.toHaveBeenCalled();
34+
});
35+
36+
it.each([
37+
"cpython-3.13.1-linux-x86_64-gnu",
38+
"cpython-3.15.0rc1+freethreaded-macos-aarch64-none",
39+
"cpython-3.15.0rc2+freethreaded-windows-x86_64-none",
40+
"pypy-3.11.15-linux-x86_64-gnu",
41+
])("returns uv's opaque runtime key unchanged: %s", async (key) => {
42+
mockExecFile.mockResolvedValue({
43+
stderr: "",
44+
stdout: `${JSON.stringify([{ key }])}\r\n`,
45+
});
46+
expect(await getPythonRuntimeId(inputs)).toBe(key);
47+
});
48+
49+
it.each(['/runner temp/a "quoted" venv', "C:\\runner temp\\custom venv"])(
50+
"queries the exact venv directory: %s",
51+
async (venvPath) => {
52+
await getPythonRuntimeId({ ...inputs, venvPath });
53+
expect(mockExecFile).toHaveBeenCalledWith(
54+
"uv",
55+
[
56+
"python",
57+
"list",
58+
venvPath,
59+
"--only-installed",
60+
"--output-format",
61+
"json",
62+
],
63+
{ encoding: "utf8" },
64+
);
65+
},
66+
);
67+
68+
it.each([
69+
new Error("uv failed"),
70+
"not JSON",
71+
"null",
72+
"{}",
73+
"[]",
74+
'[{"key":""}]',
75+
'[{"key":123}]',
76+
'[{"key":"first"},{"key":"second"}]',
77+
])("rejects uv failure or invalid results: %s", async (result) => {
78+
if (result instanceof Error) {
79+
mockExecFile.mockRejectedValue(result);
80+
} else {
81+
mockExecFile.mockResolvedValue({ stderr: "", stdout: result });
82+
}
83+
await expect(getPythonRuntimeId(inputs)).rejects.toThrow(
84+
"Failed to identify the activated environment's Python runtime:",
85+
);
86+
});

‎action-types.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,7 @@ outputs:
7979
type: string
8080
python-version:
8181
type: string
82+
python-runtime-id:
83+
type: string
8284
python-cache-hit:
8385
type: boolean

‎action.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ outputs:
107107
description: "Path to the activated venv if activate-environment is true"
108108
python-version:
109109
description: "The Python version that was set."
110+
python-runtime-id:
111+
description: "An opaque identifier reported by uv for the activated venv's Python runtime. Empty when activate-environment is false."
110112
python-cache-hit:
111113
description: "A boolean value to indicate the Python cache entry was found"
112114
runs:

‎dist/setup/index.cjs‎

Lines changed: 43 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/environment-and-tools.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ This allows directly using it in later steps:
1717
1818
By default, the venv is created at `.venv` inside the `working-directory`.
1919

20+
With `activate-environment: true`, the `python-runtime-id` output identifies the
21+
venv's Python runtime as reported by uv. This is an opaque identifier that users of the action
22+
can use as a cache key if necessary; users should not assume anything about
23+
the stability or structure of the identifier itself.
24+
25+
For example, you can combine it with the platform and dependency information relevant to
26+
your cache with `id: setup-uv` on the setup step:
27+
28+
```yaml
29+
key: build-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-uv.outputs.python-runtime-id }}-${{ hashFiles('uv.lock') }}
30+
```
31+
2032
You can customize the venv location with `venv-path`, for example to place it in the runner temp directory:
2133

2234
```yaml

‎src/setup-uv.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
getPlatform,
1717
type Platform,
1818
} from "./utils/platforms";
19+
import { getPythonRuntimeId } from "./utils/python-runtime";
1920
import { resolveUvVersion } from "./version/resolve";
2021

2122
const sourceDir = __dirname;
@@ -101,6 +102,7 @@ async function run(): Promise<void> {
101102

102103
const detectedPythonVersion = await getPythonVersion(inputs);
103104
core.setOutput("python-version", detectedPythonVersion);
105+
core.setOutput("python-runtime-id", await getPythonRuntimeId(inputs));
104106

105107
if (inputs.enableCache) {
106108
await restoreCache(inputs, detectedPythonVersion);

‎src/utils/python-runtime.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { execFile } from "node:child_process";
2+
import { promisify } from "node:util";
3+
import type { SetupInputs } from "./inputs";
4+
5+
const execFileAsync = promisify(execFile);
6+
7+
export async function getPythonRuntimeId(inputs: SetupInputs): Promise<string> {
8+
if (!inputs.activateEnvironment) {
9+
return "";
10+
}
11+
12+
try {
13+
// The venv path restricts results to this invocation's runtime, even if
14+
// earlier setup-uv calls installed other Python versions in the same job.
15+
const { stdout } = await execFileAsync(
16+
"uv",
17+
[
18+
"python",
19+
"list",
20+
inputs.venvPath,
21+
"--only-installed",
22+
"--output-format",
23+
"json",
24+
],
25+
{ encoding: "utf8" },
26+
);
27+
const pythons = JSON.parse(stdout);
28+
if (
29+
!Array.isArray(pythons) ||
30+
pythons.length !== 1 ||
31+
typeof pythons[0]?.key !== "string" ||
32+
pythons[0].key === ""
33+
) {
34+
throw new Error("Expected one installed Python with a runtime key");
35+
}
36+
return pythons[0].key;
37+
} catch (error) {
38+
throw new Error(
39+
`Failed to identify the activated environment's Python runtime: ${error instanceof Error ? error.message : String(error)}`,
40+
{ cause: error },
41+
);
42+
}
43+
}

0 commit comments

Comments
 (0)