mirror of
https://github.com/astral-sh/setup-uv.git
synced 2026-09-20 12:18:48 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+43
-8
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user