From 46f427bd47c794e99536b75ffaa9f27602425027 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 11 Aug 2026 14:26:03 +0200 Subject: [PATCH] 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 --- .github/workflows/test.yml | 26 ++++++ README.md | 24 +++++- __tests__/fixtures/.tool-versions | 1 + __tests__/utils/inputs.test.ts | 49 +++++++++++ __tests__/version/tool-versions-file.test.ts | 74 ++++++++++++++++- action.yml | 4 +- dist/save-cache/index.cjs | 78 ++++++++++++++++-- dist/setup/index.cjs | 87 ++++++++++++++++---- docs/advanced-version-configuration.md | 5 +- docs/environment-and-tools.md | 3 +- src/utils/inputs.ts | 26 +++++- src/version/tool-versions-file.ts | 72 ++++++++++++---- 12 files changed, 400 insertions(+), 49 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6b8b74..fd02974 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -234,6 +234,31 @@ jobs: exit 1 fi + test-tool-versions-python-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install versions from .tool-versions + id: setup-uv + uses: ./ + with: + version-file: "__tests__/fixtures/.tool-versions" + - name: Verify Python version from .tool-versions + run: | + if [ "$UV_PYTHON" != "3.13.1t" ]; then + echo "Wrong UV_PYTHON: $UV_PYTHON" + exit 1 + fi + if [ "$PYTHON_VERSION" != "3.13.1t" ]; then + echo "Wrong python-version output: $PYTHON_VERSION" + exit 1 + fi + shell: bash + env: + PYTHON_VERSION: ${{ steps.setup-uv.outputs.python-version }} + test-malformed-pyproject-file-fallback: runs-on: ubuntu-latest steps: @@ -1126,6 +1151,7 @@ jobs: - test-from-working-directory-version - test-malformed-pyproject-file-fallback - test-version-file-version + - test-tool-versions-python-version - test-checksum - test-with-explicit-token - test-uvx diff --git a/README.md b/README.md index bde68f8..fff6be2 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,13 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed # The version of uv to install, e.g., "0.5.0", "latest", or "latest-known" (default: searches for version in config files, then latest) version: "" - # Path to a file containing the version of uv to install, e.g., uv.toml, pyproject.toml, .tool-versions, requirements.txt or uv.lock (default: searches uv.toml then pyproject.toml) + # Path to a file containing the version of uv to install, e.g., uv.toml, pyproject.toml, .tool-versions, requirements.txt or uv.lock. A selected .tool-versions file can also provide the Python version (default: searches uv.toml then pyproject.toml) version-file: "" # Resolution strategy when resolving version ranges: 'highest' or 'lowest' resolution-strategy: "highest" - # The version of Python to set UV_PYTHON to + # The version of Python to set UV_PYTHON to (overrides the Python version from .tool-versions) python-version: "" # Use uv venv to activate a venv ready to be used by later steps @@ -144,7 +144,25 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed You can use the input `python-version` to set the environment variable `UV_PYTHON` for the rest of your workflow -This will override any python version specifications in `pyproject.toml` and `.python-version` +This will override any python version specifications in `pyproject.toml`, `.python-version`, and +an explicitly selected `.tool-versions` file. + +When `version-file` points to `.tool-versions`, its `python` entry is used if neither +`python-version` nor `UV_PYTHON` is set: + +```text +uv 0.12.3 +python 3.13 +``` + +```yaml +- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version-file: ".tool-versions" +``` + +Only a single Python version is supported. Multiple fallback versions and the asdf `ref:`, `path:`, +and `system` forms are ignored with a warning. ```yaml - name: Install the latest version of uv and set the python version to 3.13t diff --git a/__tests__/fixtures/.tool-versions b/__tests__/fixtures/.tool-versions index d1369b5..444c1aa 100644 --- a/__tests__/fixtures/.tool-versions +++ b/__tests__/fixtures/.tool-versions @@ -1 +1,2 @@ uv 0.5.15 +python 3.13.1t diff --git a/__tests__/utils/inputs.test.ts b/__tests__/utils/inputs.test.ts index 8d45e88..aad3495 100644 --- a/__tests__/utils/inputs.test.ts +++ b/__tests__/utils/inputs.test.ts @@ -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) => { diff --git a/__tests__/version/tool-versions-file.test.ts b/__tests__/version/tool-versions-file.test.ts index 044cec6..bf91b0f 100644 --- a/__tests__/version/tool-versions-file.test.ts +++ b/__tests__/version/tool-versions-file.test.ts @@ -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(); + }); +}); diff --git a/action.yml b/action.yml index c589b8f..50b05ec 100644 --- a/action.yml +++ b/action.yml @@ -7,10 +7,10 @@ inputs: description: "The version of uv to install, e.g., `0.5.0`, `latest`, or `latest-known`. Defaults to the version in pyproject.toml or `latest`." default: "" version-file: - description: "Path to a file containing the version of uv to install, e.g., uv.toml, pyproject.toml, .tool-versions, requirements.txt or uv.lock. Defaults to searching for uv.toml and if not found pyproject.toml." + description: "Path to a file containing the version of uv to install, e.g., uv.toml, pyproject.toml, .tool-versions, requirements.txt or uv.lock. A selected .tool-versions file can also provide the Python version. Defaults to searching for uv.toml and if not found pyproject.toml." default: "" python-version: - description: "The version of Python to set UV_PYTHON to" + description: "The version of Python to set UV_PYTHON to. Overrides the Python version from .tool-versions." required: false activate-environment: description: "Use uv venv to activate a venv ready to be used by later steps. " diff --git a/dist/save-cache/index.cjs b/dist/save-cache/index.cjs index 1517e5f..65cdbfe 100644 --- a/dist/save-cache/index.cjs +++ b/dist/save-cache/index.cjs @@ -26998,7 +26998,7 @@ __export(save_cache_exports, { run: () => run }); module.exports = __toCommonJS(save_cache_exports); -var fs7 = __toESM(require("node:fs"), 1); +var fs9 = __toESM(require("node:fs"), 1); // node_modules/@actions/core/lib/command.js var os = __toESM(require("os"), 1); @@ -61845,10 +61845,54 @@ var STATE_UV_PATH = "uv-path"; var STATE_UV_VERSION = "uv-version"; // src/utils/inputs.ts +var import_node_fs4 = __toESM(require("node:fs"), 1); var import_node_path = __toESM(require("node:path"), 1); -// src/utils/config-file.ts +// src/version/tool-versions-file.ts var import_node_fs2 = __toESM(require("node:fs"), 1); +function getPythonVersionFromToolVersions(filePath) { + const versions = getToolVersions(filePath, "python"); + if (versions === void 0 || versions.length === 0) { + return void 0; + } + if (versions.length > 1) { + warning( + "Multiple Python versions in .tool-versions are not supported. The Python entry will be ignored." + ); + return void 0; + } + const version3 = stripVersionPrefix(versions[0]); + if (version3 === "system" || version3.startsWith("ref:") || version3.startsWith("path:")) { + warning( + `The Python version ${versions[0]} in .tool-versions is not supported. The Python entry will be ignored.` + ); + return void 0; + } + return version3; +} +function getToolVersions(filePath, toolName) { + if (!filePath.endsWith(".tool-versions")) { + return void 0; + } + const fileContents = import_node_fs2.default.readFileSync(filePath, "utf8"); + for (const line of fileContents.split("\n")) { + const content = line.split("#", 1)[0].trim(); + if (content === "") { + continue; + } + const [tool, ...versions] = content.split(/\s+/); + if (tool === toolName) { + return versions; + } + } + return void 0; +} +function stripVersionPrefix(version3) { + return version3.startsWith("v") ? version3.slice(1) : version3; +} + +// src/utils/config-file.ts +var import_node_fs3 = __toESM(require("node:fs"), 1); // node_modules/smol-toml/dist/date.js var DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})?[T ]?(?:(\d{2}):\d{2}(?::\d{2}(?:\.\d+)?)?)?(Z|[-+]\d{2}:\d{2})?$/i; @@ -62532,10 +62576,10 @@ function parse3(toml, { maxDepth = 1e3, integersAsBigInt } = {}) { // src/utils/config-file.ts function getConfigValueFromTomlFile(filePath, key) { - if (!import_node_fs2.default.existsSync(filePath) || !filePath.endsWith(".toml")) { + if (!import_node_fs3.default.existsSync(filePath) || !filePath.endsWith(".toml")) { return void 0; } - const fileContent = import_node_fs2.default.readFileSync(filePath, "utf-8"); + const fileContent = import_node_fs3.default.readFileSync(filePath, "utf-8"); return getConfigValueFromTomlContent(filePath, fileContent, key); } function getConfigValueFromTomlContent(filePath, fileContent, key) { @@ -62555,7 +62599,7 @@ function loadInputs() { const workingDirectory = getInput("working-directory"); const version3 = getInput("version"); const versionFile = getVersionFile(workingDirectory); - const pythonVersion = getInput("python-version"); + const pythonVersion = getPythonVersion(versionFile); const activateEnvironment = getBooleanInput("activate-environment"); const noProject = getBooleanInput("no-project"); const venvPath = getVenvPath(workingDirectory, activateEnvironment); @@ -62621,6 +62665,26 @@ function getVersionFile(workingDirectory) { } return versionFileInput; } +function getPythonVersion(versionFile) { + const pythonVersionInput = getInput("python-version"); + if (pythonVersionInput !== "") { + return pythonVersionInput; + } + if (process.env.UV_PYTHON !== void 0 && process.env.UV_PYTHON !== "") { + return ""; + } + if (versionFile === "" || !import_node_fs4.default.existsSync(versionFile)) { + return ""; + } + try { + return getPythonVersionFromToolVersions(versionFile) ?? ""; + } catch (err) { + warning2( + `Error while parsing Python version from ${versionFile}: ${err.message}` + ); + return ""; + } +} function getVenvPath(workingDirectory, activateEnvironment) { const venvPathInput = getInput("venv-path"); if (venvPathInput !== "") { @@ -62864,7 +62928,7 @@ async function saveCache3(inputs) { await pruneCache(); } const actualCachePath = getUvCachePath(inputs); - if (!fs7.existsSync(actualCachePath)) { + if (!fs9.existsSync(actualCachePath)) { if (inputs.ignoreNothingToCache) { info2( "No cacheable uv cache paths were found. Ignoring because ignore-nothing-to-cache is enabled." @@ -62884,7 +62948,7 @@ async function saveCache3(inputs) { } } if (inputs.cachePython) { - if (!fs7.existsSync(inputs.pythonDir)) { + if (!fs9.existsSync(inputs.pythonDir)) { warning2( `Python cache path ${inputs.pythonDir} does not exist on disk. Skipping Python cache save because no managed Python installation was found. If you want uv to install managed Python instead of using a system interpreter, set UV_PYTHON_PREFERENCE=only-managed.` ); diff --git a/dist/setup/index.cjs b/dist/setup/index.cjs index 487f4f7..056aad4 100644 --- a/dist/setup/index.cjs +++ b/dist/setup/index.cjs @@ -56054,7 +56054,7 @@ var require_semver5 = __commonJS({ }); // src/setup-uv.ts -var import_node_fs8 = __toESM(require("node:fs"), 1); +var import_node_fs9 = __toESM(require("node:fs"), 1); var path16 = __toESM(require("node:path"), 1); // node_modules/@actions/core/lib/command.js @@ -98258,29 +98258,59 @@ function getUvVersionFromDependency(dependency) { // src/version/tool-versions-file.ts var import_node_fs4 = __toESM(require("node:fs"), 1); function getUvVersionFromToolVersions(filePath) { + const versions = getToolVersions(filePath, "uv"); + if (versions === void 0 || versions.length !== 1) { + return void 0; + } + const version3 = stripVersionPrefix(versions[0]); + if (version3.startsWith("ref")) { + warning( + "The ref syntax of .tool-versions is not supported. Please use a released version instead." + ); + return void 0; + } + return version3; +} +function getPythonVersionFromToolVersions(filePath) { + const versions = getToolVersions(filePath, "python"); + if (versions === void 0 || versions.length === 0) { + return void 0; + } + if (versions.length > 1) { + warning( + "Multiple Python versions in .tool-versions are not supported. The Python entry will be ignored." + ); + return void 0; + } + const version3 = stripVersionPrefix(versions[0]); + if (version3 === "system" || version3.startsWith("ref:") || version3.startsWith("path:")) { + warning( + `The Python version ${versions[0]} in .tool-versions is not supported. The Python entry will be ignored.` + ); + return void 0; + } + return version3; +} +function getToolVersions(filePath, toolName) { if (!filePath.endsWith(".tool-versions")) { return void 0; } const fileContents = import_node_fs4.default.readFileSync(filePath, "utf8"); - const lines = fileContents.split("\n"); - for (const line of lines) { - if (line.trim().startsWith("#")) { + for (const line of fileContents.split("\n")) { + const content = line.split("#", 1)[0].trim(); + if (content === "") { continue; } - const match2 = line.match(/^\s*uv\s*v?\s*(?[^\s]+)\s*$/); - if (match2) { - const matchedVersion = match2.groups?.version.trim(); - if (matchedVersion?.startsWith("ref")) { - warning( - "The ref syntax of .tool-versions is not supported. Please use a released version instead." - ); - return void 0; - } - return matchedVersion; + const [tool, ...versions] = content.split(/\s+/); + if (tool === toolName) { + return versions; } } return void 0; } +function stripVersionPrefix(version3) { + return version3.startsWith("v") ? version3.slice(1) : version3; +} // src/version/uv-lock-file.ts var import_node_fs5 = __toESM(require("node:fs"), 1); @@ -98734,12 +98764,13 @@ function getExtension(platform2) { } // src/utils/inputs.ts +var import_node_fs8 = __toESM(require("node:fs"), 1); var import_node_path = __toESM(require("node:path"), 1); function loadInputs() { const workingDirectory = getInput("working-directory"); const version3 = getInput("version"); const versionFile = getVersionFile(workingDirectory); - const pythonVersion = getInput("python-version"); + const pythonVersion = getPythonVersion(versionFile); const activateEnvironment2 = getBooleanInput("activate-environment"); const noProject = getBooleanInput("no-project"); const venvPath = getVenvPath(workingDirectory, activateEnvironment2); @@ -98805,6 +98836,26 @@ function getVersionFile(workingDirectory) { } return versionFileInput; } +function getPythonVersion(versionFile) { + const pythonVersionInput = getInput("python-version"); + if (pythonVersionInput !== "") { + return pythonVersionInput; + } + if (process.env.UV_PYTHON !== void 0 && process.env.UV_PYTHON !== "") { + return ""; + } + if (versionFile === "" || !import_node_fs8.default.existsSync(versionFile)) { + return ""; + } + try { + return getPythonVersionFromToolVersions(versionFile) ?? ""; + } catch (err) { + warning2( + `Error while parsing Python version from ${versionFile}: ${err.message}` + ); + return ""; + } +} function getVenvPath(workingDirectory, activateEnvironment2) { const venvPathInput = getInput("venv-path"); if (venvPathInput !== "") { @@ -99018,7 +99069,7 @@ process.on("uncaughtException", (error2) => { process.on("unhandledRejection", (reason) => { failUnexpectedly("Unhandled promise rejection", reason); }); -async function getPythonVersion(inputs) { +async function getPythonVersion2(inputs) { if (inputs.pythonVersion !== "") { return inputs.pythonVersion; } @@ -99068,7 +99119,7 @@ async function run() { setOutput("uv-version", setupResult.version); saveState(STATE_UV_VERSION, setupResult.version); info2(`Successfully installed uv version ${setupResult.version}`); - const detectedPythonVersion = await getPythonVersion(inputs); + const detectedPythonVersion = await getPythonVersion2(inputs); setOutput("python-version", detectedPythonVersion); if (inputs.enableCache) { await restoreCache2(inputs, detectedPythonVersion); @@ -99080,7 +99131,7 @@ async function run() { } } function detectEmptyWorkdir(inputs) { - if (import_node_fs8.default.readdirSync(inputs.workingDirectory).length === 0) { + if (import_node_fs9.default.readdirSync(inputs.workingDirectory).length === 0) { if (inputs.ignoreEmptyWorkdir) { info2( "Empty workdir detected. Ignoring because ignore-empty-workdir is enabled" diff --git a/docs/advanced-version-configuration.md b/docs/advanced-version-configuration.md index 4b0053a..e00daa5 100644 --- a/docs/advanced-version-configuration.md +++ b/docs/advanced-version-configuration.md @@ -85,7 +85,10 @@ You can use the `version-file` input to specify a file that contains the version This can either be a `pyproject.toml` or `uv.toml` file which defines a `required-version` or uv defined as a dependency in `pyproject.toml` or `requirements.txt`. -[asdf](https://asdf-vm.com/) `.tool-versions` is also supported, but without the `ref` syntax. +[asdf](https://asdf-vm.com/) `.tool-versions` is also supported for selecting uv. If neither +`python-version` nor `UV_PYTHON` is set, the `python` entry from the selected file is also exported +as `UV_PYTHON`. Only a single Python version is supported; multiple fallback versions and the asdf +`ref:`, `path:`, and `system` forms are ignored with a warning. ```yaml - name: Install uv based on the version defined in pyproject.toml diff --git a/docs/environment-and-tools.md b/docs/environment-and-tools.md index e7e0622..9f0a9f1 100644 --- a/docs/environment-and-tools.md +++ b/docs/environment-and-tools.md @@ -131,7 +131,7 @@ If you want to ignore this, set the `ignore-empty-workdir` input to `true`. This action sets several environment variables that influence uv's behavior and can be used by subsequent steps: -- `UV_PYTHON`: Set when `python-version` input is specified. Controls which Python version uv uses. +- `UV_PYTHON`: Set when `python-version` is specified or the selected `.tool-versions` file contains a supported `python` entry. Controls which Python version uv uses. - `UV_CACHE_DIR`: Set when caching is enabled (unless already configured in uv config files). Controls where uv stores its cache. - `UV_TOOL_DIR`: Set when `tool-dir` input is specified. Controls where uv installs tool environments. - `UV_TOOL_BIN_DIR`: Set when `tool-bin-dir` input is specified. Controls where uv installs tool binaries. @@ -142,6 +142,7 @@ This action sets several environment variables that influence uv's behavior and - `UV_NO_MODIFY_PATH`: If set, prevents the action from modifying PATH. Cannot be used with `activate-environment`. - `UV_CACHE_DIR`: If already set, the action will respect it instead of setting its own cache directory. +- `UV_PYTHON`: If already set and `python-version` is not specified, the action will respect it instead of using the `python` entry from `.tool-versions`. ```yaml - name: Example using environment variables diff --git a/src/utils/inputs.ts b/src/utils/inputs.ts index efe4b5c..74a75d8 100644 --- a/src/utils/inputs.ts +++ b/src/utils/inputs.ts @@ -1,5 +1,7 @@ +import fs from "node:fs"; import path from "node:path"; import * as core from "@actions/core"; +import { getPythonVersionFromToolVersions } from "../version/tool-versions-file"; import { getConfigValueFromTomlFile } from "./config-file"; import * as log from "./logging"; @@ -51,7 +53,7 @@ export function loadInputs(): SetupInputs { const workingDirectory = core.getInput("working-directory"); const version = core.getInput("version"); const versionFile = getVersionFile(workingDirectory); - const pythonVersion = core.getInput("python-version"); + const pythonVersion = getPythonVersion(versionFile); const activateEnvironment = core.getBooleanInput("activate-environment"); const noProject = core.getBooleanInput("no-project"); const venvPath = getVenvPath(workingDirectory, activateEnvironment); @@ -122,6 +124,28 @@ function getVersionFile(workingDirectory: string): string { return versionFileInput; } +function getPythonVersion(versionFile: string): string { + const pythonVersionInput = core.getInput("python-version"); + if (pythonVersionInput !== "") { + return pythonVersionInput; + } + if (process.env.UV_PYTHON !== undefined && process.env.UV_PYTHON !== "") { + return ""; + } + if (versionFile === "" || !fs.existsSync(versionFile)) { + return ""; + } + + try { + return getPythonVersionFromToolVersions(versionFile) ?? ""; + } catch (err) { + log.warning( + `Error while parsing Python version from ${versionFile}: ${(err as Error).message}`, + ); + return ""; + } +} + function getVenvPath( workingDirectory: string, activateEnvironment: boolean, diff --git a/src/version/tool-versions-file.ts b/src/version/tool-versions-file.ts index c0ddf3c..c18abcc 100644 --- a/src/version/tool-versions-file.ts +++ b/src/version/tool-versions-file.ts @@ -4,28 +4,72 @@ import * as core from "@actions/core"; export function getUvVersionFromToolVersions( filePath: string, ): string | undefined { + const versions = getToolVersions(filePath, "uv"); + if (versions === undefined || versions.length !== 1) { + return undefined; + } + + const version = stripVersionPrefix(versions[0]); + if (version.startsWith("ref")) { + core.warning( + "The ref syntax of .tool-versions is not supported. Please use a released version instead.", + ); + return undefined; + } + return version; +} + +export function getPythonVersionFromToolVersions( + filePath: string, +): string | undefined { + const versions = getToolVersions(filePath, "python"); + if (versions === undefined || versions.length === 0) { + return undefined; + } + if (versions.length > 1) { + core.warning( + "Multiple Python versions in .tool-versions are not supported. The Python entry will be ignored.", + ); + return undefined; + } + + const version = stripVersionPrefix(versions[0]); + if ( + version === "system" || + version.startsWith("ref:") || + version.startsWith("path:") + ) { + core.warning( + `The Python version ${versions[0]} in .tool-versions is not supported. The Python entry will be ignored.`, + ); + return undefined; + } + return version; +} + +function getToolVersions( + filePath: string, + toolName: string, +): string[] | undefined { if (!filePath.endsWith(".tool-versions")) { return undefined; } const fileContents = fs.readFileSync(filePath, "utf8"); - const lines = fileContents.split("\n"); - for (const line of lines) { - // Skip commented lines - if (line.trim().startsWith("#")) { + for (const line of fileContents.split("\n")) { + const content = line.split("#", 1)[0].trim(); + if (content === "") { continue; } - const match = line.match(/^\s*uv\s*v?\s*(?[^\s]+)\s*$/); - if (match) { - const matchedVersion = match.groups?.version.trim(); - if (matchedVersion?.startsWith("ref")) { - core.warning( - "The ref syntax of .tool-versions is not supported. Please use a released version instead.", - ); - return undefined; - } - return matchedVersion; + + const [tool, ...versions] = content.split(/\s+/); + if (tool === toolName) { + return versions; } } return undefined; } + +function stripVersionPrefix(version: string): string { + return version.startsWith("v") ? version.slice(1) : version; +}