Add a Python architecture input

This commit is contained in:
Zanie Blue
2026-09-30 11:26:40 -05:00
parent bcf05803b5
commit 7cc9e96834
15 changed files with 1544 additions and 1108 deletions

View File

@@ -1,9 +1,20 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals";
import { createSetupInputs } from "../helpers/setup-inputs";
const mockRestoreCache = jest.fn();
const mockSaveState = jest.fn();
const mockSetOutput = jest.fn();
const mockGetArch = jest.fn(() => "x86_64");
const mockGetOSNameVersion = jest.fn(() => "ubuntu-24.04");
const mockGetPlatform = jest.fn(async () => "unknown-linux-gnu");
const ORIGINAL_UV_PYTHON_ARCH = process.env.UV_PYTHON_ARCH;
jest.unstable_mockModule("@actions/cache", () => ({
restoreCache: mockRestoreCache,
@@ -24,9 +35,9 @@ jest.unstable_mockModule("../../src/utils/logging", () => ({
}));
jest.unstable_mockModule("../../src/utils/platforms", () => ({
getArch: jest.fn(() => "x86_64"),
getOSNameVersion: jest.fn(() => "ubuntu-24.04"),
getPlatform: jest.fn(async () => "unknown-linux-gnu"),
getArch: mockGetArch,
getOSNameVersion: mockGetOSNameVersion,
getPlatform: mockGetPlatform,
}));
const { restoreCache } = await import("../../src/cache/restore-cache");
@@ -38,7 +49,19 @@ function cacheKeyOutput(): string {
}
beforeEach(() => {
delete process.env.UV_PYTHON_ARCH;
jest.clearAllMocks();
mockGetArch.mockReturnValue("x86_64");
mockGetOSNameVersion.mockReturnValue("ubuntu-24.04");
mockGetPlatform.mockResolvedValue("unknown-linux-gnu");
});
afterEach(() => {
if (ORIGINAL_UV_PYTHON_ARCH === undefined) {
delete process.env.UV_PYTHON_ARCH;
} else {
process.env.UV_PYTHON_ARCH = ORIGINAL_UV_PYTHON_ARCH;
}
});
describe("restoreCache", () => {
@@ -71,4 +94,39 @@ describe("restoreCache", () => {
"setup-uv-2-x86_64-unknown-linux-gnu-ubuntu-24.04-3.11-dependencyhash-tests-3.11",
);
});
it("includes an inherited Python architecture in the cache key", async () => {
process.env.UV_PYTHON_ARCH = "aarch64";
await restoreCache(createSetupInputs(), "3.14");
expect(cacheKeyOutput()).toContain("-3.14-python-aarch64-");
});
it.each(["aarch64", "x86_64"])(
"separates %s Python caches on a Windows ARM64 runner",
async (pythonArch) => {
mockGetArch.mockReturnValue("aarch64");
mockGetOSNameVersion.mockReturnValue("windows-11");
mockGetPlatform.mockResolvedValue("pc-windows-msvc");
const inputs = createSetupInputs({
cachePython: true,
pythonArch,
restoreCache: true,
});
await restoreCache(inputs, "3.14");
const cacheKey = `setup-uv-2-aarch64-pc-windows-msvc-windows-11-3.14-python-${pythonArch}-py-dependencyhash`;
expect(cacheKeyOutput()).toBe(cacheKey);
expect(mockRestoreCache).toHaveBeenCalledWith(
[inputs.cacheLocalPath?.path],
cacheKey,
);
expect(mockRestoreCache).toHaveBeenCalledWith(
[inputs.pythonDir],
`${cacheKey}-python`,
);
},
);
});

View File

@@ -21,6 +21,7 @@ export function createSetupInputs(
ignoreNothingToCache: false,
noProject: false,
pruneCache: false,
pythonArch: "",
pythonDir: "/tmp/uv-python-dir",
pythonVersion: "",
quiet: false,

View File

@@ -19,6 +19,7 @@ const ORIGINAL_RUNNER_ENVIRONMENT = process.env.RUNNER_ENVIRONMENT;
const ORIGINAL_RUNNER_TEMP = process.env.RUNNER_TEMP;
const ORIGINAL_UV_CACHE_DIR = process.env.UV_CACHE_DIR;
const ORIGINAL_UV_PYTHON = process.env.UV_PYTHON;
const ORIGINAL_UV_PYTHON_ARCH = process.env.UV_PYTHON_ARCH;
const ORIGINAL_UV_PYTHON_INSTALL_DIR = process.env.UV_PYTHON_INSTALL_DIR;
const mockDebug = jest.fn();
@@ -37,7 +38,9 @@ jest.unstable_mockModule("@actions/core", () => ({
warning: mockWarning,
}));
const { CacheLocalSource, loadInputs } = await import("../../src/utils/inputs");
const { CacheLocalSource, loadInputs, resolvePythonArch } = await import(
"../../src/utils/inputs"
);
function createTempProject(files: Record<string, string> = {}): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "setup-uv-inputs-test-"));
@@ -62,6 +65,7 @@ function resetEnvironment(): void {
delete process.env.RUNNER_TEMP;
delete process.env.UV_CACHE_DIR;
delete process.env.UV_PYTHON;
delete process.env.UV_PYTHON_ARCH;
delete process.env.UV_PYTHON_INSTALL_DIR;
}
@@ -77,6 +81,11 @@ function restoreEnvironment(): void {
process.env.RUNNER_TEMP = ORIGINAL_RUNNER_TEMP;
process.env.UV_CACHE_DIR = ORIGINAL_UV_CACHE_DIR;
process.env.UV_PYTHON = ORIGINAL_UV_PYTHON;
if (ORIGINAL_UV_PYTHON_ARCH === undefined) {
delete process.env.UV_PYTHON_ARCH;
} else {
process.env.UV_PYTHON_ARCH = ORIGINAL_UV_PYTHON_ARCH;
}
process.env.UV_PYTHON_INSTALL_DIR = ORIGINAL_UV_PYTHON_INSTALL_DIR;
}
@@ -98,6 +107,7 @@ describe("loadInputs", () => {
source: CacheLocalSource.Default,
});
expect(inputs.pythonDir).toBe("/runner-temp/uv-python-dir");
expect(inputs.pythonArch).toBe("");
expect(inputs.venvPath).toBe("/workspace/.venv");
expect(inputs.manifestFile).toBeUndefined();
expect(inputs.resolutionStrategy).toBe("highest");
@@ -149,6 +159,28 @@ describe("loadInputs", () => {
expect(inputs.pythonVersion).toBe("");
});
it("prefers the python-arch input over UV_PYTHON_ARCH", () => {
mockInputs["working-directory"] = "/workspace";
mockInputs["python-arch"] = "x86_64";
process.env.UV_PYTHON_ARCH = "aarch64";
const inputs = loadInputs();
expect(inputs.pythonArch).toBe("x86_64");
expect(resolvePythonArch(inputs.pythonArch)).toBe("x86_64");
});
it.each(["aarch64", "x86_64_v3", ""])(
"uses UV_PYTHON_ARCH when python-arch is omitted: %s",
(pythonArch) => {
mockInputs["working-directory"] = "/workspace";
process.env.UV_PYTHON_ARCH = pythonArch;
const inputs = loadInputs();
expect(inputs.pythonArch).toBe("");
expect(resolvePythonArch(inputs.pythonArch)).toBe(pythonArch);
},
);
it.each(["pull_request_target", "workflow_run", "release"])(
"disables automatic caching for the %s event",
(eventName) => {

View File

@@ -0,0 +1,152 @@
import { promisify } from "node:util";
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals";
const ORIGINAL_UV_PYTHON_ARCH = process.env.UV_PYTHON_ARCH;
const mockExecFile =
jest.fn<
(...args: unknown[]) => Promise<{
stdout: string;
stderr: string;
}>
>();
const mockExportVariable = jest.fn();
jest.unstable_mockModule("node:child_process", () => ({
execFile: Object.assign(mockExecFile, { [promisify.custom]: mockExecFile }),
}));
jest.unstable_mockModule("@actions/core", () => ({
exportVariable: mockExportVariable,
}));
jest.unstable_mockModule("../../src/utils/logging", () => ({
info: jest.fn(),
}));
const { setupPythonArch } = await import("../../src/utils/python-arch");
const supportedProbe = Object.assign(new Error("uv exited with code 2"), {
code: 2,
stderr:
"error: Failed to parse environment variable `UV_PYTHON_ARCH` with invalid value `setup-uv-probe`: Unknown architecture: setup-uv-probe\n",
});
const success = { stderr: "", stdout: "/cache\n" };
beforeEach(() => {
delete process.env.UV_PYTHON_ARCH;
jest.clearAllMocks();
mockExecFile
.mockReset()
.mockRejectedValueOnce(supportedProbe)
.mockResolvedValueOnce(success);
});
afterEach(() => {
if (ORIGINAL_UV_PYTHON_ARCH === undefined) {
delete process.env.UV_PYTHON_ARCH;
} else {
process.env.UV_PYTHON_ARCH = ORIGINAL_UV_PYTHON_ARCH;
}
});
describe("setupPythonArch", () => {
it("leaves Python selection alone without an architecture", async () => {
await setupPythonArch("/tools/uv", "");
expect(mockExecFile).not.toHaveBeenCalled();
expect(mockExportVariable).not.toHaveBeenCalled();
});
it("validates an inherited architecture without exporting it", async () => {
process.env.UV_PYTHON_ARCH = "aarch64";
await setupPythonArch("/tools/uv", "");
expect(mockExecFile).toHaveBeenNthCalledWith(
2,
"/tools/uv",
["--no-config", "cache", "dir"],
expect.objectContaining({
env: expect.objectContaining({ UV_PYTHON_ARCH: "aarch64" }),
}),
);
expect(mockExportVariable).not.toHaveBeenCalled();
expect(process.env.UV_PYTHON_ARCH).toBe("aarch64");
});
it.each(["/runner temp/uv", "C:\\runner temp\\uv.exe"])(
"validates and exports the architecture using the installed uv: %s",
async (uvPath) => {
await setupPythonArch(uvPath, "x86_64");
expect(mockExecFile).toHaveBeenNthCalledWith(
1,
uvPath,
["--no-config", "cache", "dir"],
expect.objectContaining({
encoding: "utf8",
env: expect.objectContaining({ UV_PYTHON_ARCH: "setup-uv-probe" }),
}),
);
expect(mockExecFile).toHaveBeenNthCalledWith(
2,
uvPath,
["--no-config", "cache", "dir"],
expect.objectContaining({
env: expect.objectContaining({ UV_PYTHON_ARCH: "x86_64" }),
}),
);
expect(mockExportVariable).toHaveBeenCalledWith(
"UV_PYTHON_ARCH",
"x86_64",
);
},
);
it("rejects uv versions that ignore UV_PYTHON_ARCH", async () => {
mockExecFile.mockReset().mockResolvedValue(success);
await expect(setupPythonArch("/tools/uv", "x86_64")).rejects.toThrow(
"The installed version of uv does not support UV_PYTHON_ARCH",
);
expect(mockExecFile).toHaveBeenCalledTimes(1);
expect(mockExportVariable).not.toHaveBeenCalled();
});
it("reports unexpected probe failures", async () => {
mockExecFile.mockReset().mockRejectedValue(
Object.assign(new Error("uv exited with code 2"), {
code: 2,
stderr: "error: unrelated configuration error\n",
}),
);
await expect(setupPythonArch("/tools/uv", "x86_64")).rejects.toThrow(
"Failed to check uv's support for UV_PYTHON_ARCH: error: unrelated configuration error",
);
expect(mockExportVariable).not.toHaveBeenCalled();
});
it("reports invalid architectures before exporting them", async () => {
mockExecFile
.mockReset()
.mockRejectedValueOnce(supportedProbe)
.mockRejectedValueOnce(
Object.assign(new Error("uv exited with code 2"), {
code: 2,
stderr: "error: Unknown architecture: invalid\n",
}),
);
await expect(setupPythonArch("/tools/uv", "invalid")).rejects.toThrow(
"Failed to set Python architecture to invalid: error: Unknown architecture: invalid",
);
expect(mockExportVariable).not.toHaveBeenCalled();
});
});