Read Python version from .tool-versions (#996)

## Summary
- read the Python version from an explicitly selected `.tool-versions`
file
- preserve `python-version` and existing `UV_PYTHON` precedence
- add parser, input, and workflow coverage and update documentation and
bundled action artifacts

## Validation
- `npm run all`
- `actionlint .github/workflows/test.yml`
- `uvx zizmor .github/workflows/test.yml`

Closes #983

Refs: pi-session 019ff01a-544c-79f3-8f73-a00132af39f5
This commit is contained in:
Kevin Stillhammer
2026-08-11 14:26:03 +02:00
committed by GitHub
parent 8ed89c5114
commit 46f427bd47
12 changed files with 400 additions and 49 deletions
+1
View File
@@ -1 +1,2 @@
uv 0.5.15
python 3.13.1t
+49
View File
@@ -18,6 +18,7 @@ const ORIGINAL_HOME = process.env.HOME;
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_INSTALL_DIR = process.env.UV_PYTHON_INSTALL_DIR;
const mockDebug = jest.fn();
@@ -60,6 +61,7 @@ function resetEnvironment(): void {
delete process.env.RUNNER_ENVIRONMENT;
delete process.env.RUNNER_TEMP;
delete process.env.UV_CACHE_DIR;
delete process.env.UV_PYTHON;
delete process.env.UV_PYTHON_INSTALL_DIR;
}
@@ -74,6 +76,7 @@ function restoreEnvironment(): void {
process.env.RUNNER_ENVIRONMENT = ORIGINAL_RUNNER_ENVIRONMENT;
process.env.RUNNER_TEMP = ORIGINAL_RUNNER_TEMP;
process.env.UV_CACHE_DIR = ORIGINAL_UV_CACHE_DIR;
process.env.UV_PYTHON = ORIGINAL_UV_PYTHON;
process.env.UV_PYTHON_INSTALL_DIR = ORIGINAL_UV_PYTHON_INSTALL_DIR;
}
@@ -100,6 +103,52 @@ describe("loadInputs", () => {
expect(inputs.resolutionStrategy).toBe("highest");
});
it("uses the Python version from an explicitly selected .tool-versions file", () => {
mockInputs["working-directory"] = createTempProject({
".tool-versions": "uv 0.12.3\npython 3.13.1t\n",
});
mockInputs["version-file"] = ".tool-versions";
const inputs = loadInputs();
expect(inputs.pythonVersion).toBe("3.13.1t");
});
it("prefers the python-version input over .tool-versions", () => {
mockInputs["working-directory"] = createTempProject({
".tool-versions": "uv 0.12.3\npython 3.13\n",
});
mockInputs["version-file"] = ".tool-versions";
mockInputs["python-version"] = "3.12";
const inputs = loadInputs();
expect(inputs.pythonVersion).toBe("3.12");
});
it("preserves UV_PYTHON instead of overriding it from .tool-versions", () => {
mockInputs["working-directory"] = createTempProject({
".tool-versions": "uv 0.12.3\npython 3.13\n",
});
mockInputs["version-file"] = ".tool-versions";
process.env.UV_PYTHON = "3.11";
const inputs = loadInputs();
expect(inputs.pythonVersion).toBe("");
expect(process.env.UV_PYTHON).toBe("3.11");
});
it("does not discover .tool-versions from the working directory", () => {
mockInputs["working-directory"] = createTempProject({
".tool-versions": "uv 0.12.3\npython 3.13\n",
});
const inputs = loadInputs();
expect(inputs.pythonVersion).toBe("");
});
it.each(["pull_request_target", "workflow_run", "release"])(
"disables automatic caching for the %s event",
(eventName) => {
+72 -2
View File
@@ -21,6 +21,11 @@ async function getVersionFromToolVersions(filePath: string) {
return getUvVersionFromToolVersions(filePath);
}
async function getPythonVersionFromToolVersions(filePath: string) {
const module = await import("../../src/version/tool-versions-file");
return module.getPythonVersionFromToolVersions(filePath);
}
describe("getUvVersionFromToolVersions", () => {
beforeEach(() => {
jest.resetModules();
@@ -61,8 +66,8 @@ describe("getUvVersionFromToolVersions", () => {
expect(result).toBe("0.3.0");
});
it("should skip commented lines", async () => {
const fileContent = "# uv 0.1.0\npython 3.11.0\nuv 0.2.0";
it("should skip comments", async () => {
const fileContent = "# uv 0.1.0\npython 3.11.0\nuv 0.2.0 # inline comment";
mockReadFileSync.mockReturnValue(fileContent);
const result = await getVersionFromToolVersions(".tool-versions");
@@ -121,3 +126,68 @@ describe("getUvVersionFromToolVersions", () => {
);
});
});
describe("getPythonVersionFromToolVersions", () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
it("should return version for a valid Python entry", async () => {
mockReadFileSync.mockReturnValue(
"nodejs 24.0.0\r\npython v3.13.1t # use free-threaded Python\r\nuv 0.12.3",
);
const result = await getPythonVersionFromToolVersions(".tool-versions");
expect(result).toBe("3.13.1t");
});
it("should return the first matching Python version", async () => {
mockReadFileSync.mockReturnValue("python 3.12\npython 3.13");
const result = await getPythonVersionFromToolVersions(".tool-versions");
expect(result).toBe("3.12");
});
it("should return undefined when no Python entry is found", async () => {
mockReadFileSync.mockReturnValue("uv 0.12.3\nnodejs 24.0.0");
const result = await getPythonVersionFromToolVersions(".tool-versions");
expect(result).toBeUndefined();
});
it("should warn and return undefined for multiple Python versions", async () => {
mockReadFileSync.mockReturnValue("python 3.13 3.12 system");
const result = await getPythonVersionFromToolVersions(".tool-versions");
expect(result).toBeUndefined();
expect(mockWarning).toHaveBeenCalledWith(
"Multiple Python versions in .tool-versions are not supported. The Python entry will be ignored.",
);
});
it.each(["ref:main", "path:~/src/python", "system"])(
"should warn and return undefined for %s",
async (version) => {
mockReadFileSync.mockReturnValue(`python ${version}`);
const result = await getPythonVersionFromToolVersions(".tool-versions");
expect(result).toBeUndefined();
expect(mockWarning).toHaveBeenCalledWith(
`The Python version ${version} in .tool-versions is not supported. The Python entry will be ignored.`,
);
},
);
it("should return undefined for non-.tool-versions files", async () => {
const result = await getPythonVersionFromToolVersions(".python-version");
expect(result).toBeUndefined();
expect(mockReadFileSync).not.toHaveBeenCalled();
});
});