From 0ce1b0bf8b818ef400413f810f8a11cdbda0034b Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sun, 12 Apr 2026 13:44:40 +0200 Subject: [PATCH] refactor version resolving (#353) --- README.md | 20 +- __tests__/download/download-version.test.ts | 4 +- __tests__/utils/pyproject.test.ts | 193 -------- __tests__/version/file-parser.test.ts | 128 +++++ .../version/version-request-resolver.test.ts | 164 +++++++ action.yml | 4 +- dist/ruff-action/index.cjs | 462 +++++++++++++----- src/download/download-version.ts | 51 +- src/ruff-action.ts | 49 +- src/utils/pyproject.ts | 130 ----- src/version/file-parser.ts | 185 +++++++ src/version/resolve.ts | 125 +++++ src/version/specifier.ts | 57 +++ src/version/types.ts | 27 + src/version/version-request-resolver.ts | 162 ++++++ 15 files changed, 1210 insertions(+), 551 deletions(-) delete mode 100644 __tests__/utils/pyproject.test.ts create mode 100644 __tests__/version/file-parser.test.ts create mode 100644 __tests__/version/version-request-resolver.test.ts delete mode 100644 src/utils/pyproject.ts create mode 100644 src/version/file-parser.ts create mode 100644 src/version/resolve.ts create mode 100644 src/version/specifier.ts create mode 100644 src/version/types.ts create mode 100644 src/version/version-request-resolver.ts diff --git a/README.md b/README.md index 0d14262..aa5d2c2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ anything `ruff` can (ex, fix). | Input | Description | Default | |-----------------|--------------------------------------------------------------------------------------------------------------------------------------------|--------------------| -| `version` | The version of Ruff to install. See [Install specific versions](#install-specific-versions) | `latest` | +| `version` | The version of Ruff to install. See [Install specific versions](#install-specific-versions) | discovered from `pyproject.toml`, else `latest` | | `version-file` | The file to read the version from. See [Install a version from a specified version file](#install-a-version-from-a-specified-version-file) | None | | `manifest-file` | URL to a custom Ruff manifest in the `astral-sh/versions` format. | None | | `args` | The arguments to pass to the `ruff` command. See [Configuring Ruff] | `check` | @@ -95,10 +95,10 @@ you can use the `args` input to overwrite the default value (`check`): ### Install specific versions -By default this action looks for a pyproject.toml file in the root of the repository to determine -the ruff version to install. If no pyproject.toml file is found, or no ruff version is defined in -`project.dependencies`, `project.optional-dependencies`, or `dependency-groups`, -the latest version is installed. +By default this action searches upward from `src` until the workspace root to find the nearest +`pyproject.toml` and determine the Ruff version to install. If no `pyproject.toml` file is found, +or no Ruff version is defined in `project.dependencies`, `project.optional-dependencies`, +`dependency-groups`, or supported Poetry dependency tables, the latest version is installed. > [!NOTE] > This action does only support ruff versions v0.0.247 and above. @@ -151,7 +151,8 @@ to install the latest version that satisfies the range. #### Install a version from a specified version file You can specify a file to read the version from. -Currently `pyproject.toml` and `requirements.txt` are supported. +Currently `pyproject.toml` and `requirements.txt` are supported. If the file cannot be parsed +or does not contain a Ruff version, the action warns and falls back to `latest`. ```yaml - name: Install a version from a specified version file @@ -160,6 +161,13 @@ Currently `pyproject.toml` and `requirements.txt` are supported. version-file: "my-path/to/pyproject.toml-or-requirements.txt" ``` +Version resolution precedence is: + +1. `version` +2. `version-file` +3. nearest discoverable `pyproject.toml` found by searching upward from `src` +4. `latest` + #### Install using a custom manifest URL You can override the default `astral-sh/versions` manifest with `manifest-file`. diff --git a/__tests__/download/download-version.test.ts b/__tests__/download/download-version.test.ts index 950224a..a99dfd9 100644 --- a/__tests__/download/download-version.test.ts +++ b/__tests__/download/download-version.test.ts @@ -47,15 +47,17 @@ const mockCopyFile = jest.fn(); const mockReaddir = jest.fn(); jest.unstable_mockModule("node:fs", () => ({ + default: {}, promises: { copyFile: mockCopyFile, readdir: mockReaddir, }, })); -const { downloadVersion, resolveVersion, rewriteToMirror } = await import( +const { downloadVersion, rewriteToMirror } = await import( "../../src/download/download-version" ); +const { resolveVersion } = await import("../../src/version/resolve"); describe("download-version", () => { beforeEach(() => { diff --git a/__tests__/utils/pyproject.test.ts b/__tests__/utils/pyproject.test.ts deleted file mode 100644 index 4e54829..0000000 --- a/__tests__/utils/pyproject.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from "@jest/globals"; - -const info = jest.fn(); -const warning = jest.fn(); - -jest.unstable_mockModule("@actions/core", () => ({ - info, - warning, -})); - -const { findRuffVersionInSpec } = await import("../../src/utils/pyproject"); - -describe("findRuffVersionInSpec", () => { - beforeEach(() => { - info.mockReset(); - warning.mockReset(); - }); - - describe("ruff dependency strings", () => { - it("should extract version from 'ruff==0.9.3'", () => { - const result = findRuffVersionInSpec("ruff==0.9.3"); - expect(result).toBe("0.9.3"); - expect(info).toHaveBeenCalledWith( - "Found ruff version in requirements file: 0.9.3", - ); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should extract version from 'ruff>=0.14'", () => { - const result = findRuffVersionInSpec("ruff>=0.14"); - expect(result).toBe(">=0.14"); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should extract version from 'ruff ~=1.0.0'", () => { - const result = findRuffVersionInSpec("ruff ~=1.0.0"); - expect(result).toBe("~=1.0.0"); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should extract version from 'ruff>=0.14,<1.0'", () => { - const result = findRuffVersionInSpec("ruff>=0.14,<1.0"); - expect(result).toBe(">=0.14,<1.0"); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should extract version from 'ruff>=0.14,<2.0,!=1.5.0'", () => { - const result = findRuffVersionInSpec("ruff>=0.14,<2.0,!=1.5.0"); - expect(result).toBe(">=0.14,<2.0,!=1.5.0"); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should return undefined for non-ruff dependency 'another-dep 0.1.6'", () => { - const result = findRuffVersionInSpec("another-dep 0.1.6"); - expect(result).toBeUndefined(); - expect(info).not.toHaveBeenCalled(); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should return undefined for non-ruff dependency 'another-dep==0.1.6'", () => { - const result = findRuffVersionInSpec("another-dep==0.1.6"); - expect(result).toBeUndefined(); - expect(info).not.toHaveBeenCalled(); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should strip trailing backslash", () => { - const result = findRuffVersionInSpec("ruff==0.9.3 \\"); - expect(result).toBe("0.9.3"); - expect(info).toHaveBeenCalledWith( - "Found ruff version in requirements file: 0.9.3", - ); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should strip trailing backslash with whitespace", () => { - const result = findRuffVersionInSpec(" ruff==0.9.3 \\ "); - expect(result).toBe("0.9.3"); - expect(warning).not.toHaveBeenCalled(); - }); - }); - - describe("environment markers", () => { - it("should strip python_version environment marker", () => { - const result = findRuffVersionInSpec( - 'ruff>=0.14 ; python_version >= "3.11"', - ); - expect(result).toBe(">=0.14"); - expect(info).toHaveBeenCalledWith( - "Found ruff version in requirements file: >=0.14", - ); - expect(warning).toHaveBeenCalledWith( - "Environment markers are ignored. ruff is a standalone tool that works independently of Python version.", - ); - }); - - it("should strip sys_platform environment marker", () => { - const result = findRuffVersionInSpec( - "ruff==0.9.3 ; sys_platform == 'linux'", - ); - expect(result).toBe("0.9.3"); - expect(warning).toHaveBeenCalledWith( - "Environment markers are ignored. ruff is a standalone tool that works independently of Python version.", - ); - }); - - it("should strip multiple environment markers", () => { - const result = findRuffVersionInSpec( - 'ruff>=0.14 ; python_version >= "3.11" and sys_platform == "linux"', - ); - expect(result).toBe(">=0.14"); - expect(warning).toHaveBeenCalledWith( - "Environment markers are ignored. ruff is a standalone tool that works independently of Python version.", - ); - }); - - it("should handle environment markers with multiple constraints", () => { - const result = findRuffVersionInSpec( - 'ruff>=0.14,<1.0 ; python_version >= "3.11"', - ); - expect(result).toBe(">=0.14,<1.0"); - expect(warning).toHaveBeenCalledWith( - "Environment markers are ignored. ruff is a standalone tool that works independently of Python version.", - ); - }); - }); - - describe("edge cases", () => { - it("should handle whitespace", () => { - const result = findRuffVersionInSpec(" ruff >=0.14 "); - expect(result).toBe(">=0.14"); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should handle whitespace with environment markers", () => { - const result = findRuffVersionInSpec( - " ruff >=0.14 ; python_version >= '3.11' ", - ); - expect(result).toBe(">=0.14"); - expect(warning).toHaveBeenCalled(); - }); - - it("should return undefined for empty string", () => { - const result = findRuffVersionInSpec(""); - expect(result).toBeUndefined(); - expect(info).not.toHaveBeenCalled(); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should return undefined for whitespace only", () => { - const result = findRuffVersionInSpec(" "); - expect(result).toBeUndefined(); - expect(info).not.toHaveBeenCalled(); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should return undefined for just semicolon", () => { - const result = findRuffVersionInSpec(";"); - expect(result).toBeUndefined(); - expect(info).not.toHaveBeenCalled(); - expect(warning).not.toHaveBeenCalled(); - }); - - it("should handle exact example from issue #256", () => { - const result = findRuffVersionInSpec( - 'ruff>=0.14 ; python_version >= "3.11"', - ); - expect(result).toBe(">=0.14"); - expect(info).toHaveBeenCalledWith( - "Found ruff version in requirements file: >=0.14", - ); - expect(warning).toHaveBeenCalledWith( - "Environment markers are ignored. ruff is a standalone tool that works independently of Python version.", - ); - }); - - it("should handle single-quoted environment markers", () => { - const result = findRuffVersionInSpec( - "ruff>=0.14 ; python_version >= '3.11'", - ); - expect(result).toBe(">=0.14"); - expect(warning).toHaveBeenCalled(); - }); - - it("should handle double-quoted environment markers", () => { - const result = findRuffVersionInSpec( - 'ruff>=0.14 ; python_version >= "3.11"', - ); - expect(result).toBe(">=0.14"); - expect(warning).toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/version/file-parser.test.ts b/__tests__/version/file-parser.test.ts new file mode 100644 index 0000000..c0c2cd9 --- /dev/null +++ b/__tests__/version/file-parser.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +const info = jest.fn(); +const warning = jest.fn(); + +jest.unstable_mockModule("@actions/core", () => ({ + debug: jest.fn(), + info, + warning, +})); + +const { findRuffVersionInSpec, getRuffVersionFromFile } = await import( + "../../src/version/file-parser" +); + +describe("file-parser", () => { + beforeEach(() => { + info.mockReset(); + warning.mockReset(); + }); + + describe("findRuffVersionInSpec", () => { + it("extracts version from 'ruff==0.9.3'", () => { + const result = findRuffVersionInSpec("ruff==0.9.3"); + expect(result).toBe("0.9.3"); + expect(info).toHaveBeenCalledWith( + "Found ruff version in requirements file: 0.9.3", + ); + expect(warning).not.toHaveBeenCalled(); + }); + + it("extracts version from 'ruff>=0.14'", () => { + const result = findRuffVersionInSpec("ruff>=0.14"); + expect(result).toBe(">=0.14"); + expect(warning).not.toHaveBeenCalled(); + }); + + it("extracts version from 'ruff ~=1.0.0'", () => { + const result = findRuffVersionInSpec("ruff ~=1.0.0"); + expect(result).toBe("~=1.0.0"); + expect(warning).not.toHaveBeenCalled(); + }); + + it("extracts version from 'ruff>=0.14,<1.0'", () => { + const result = findRuffVersionInSpec("ruff>=0.14,<1.0"); + expect(result).toBe(">=0.14,<1.0"); + expect(warning).not.toHaveBeenCalled(); + }); + + it("extracts version from 'ruff>=0.14,<2.0,!=1.5.0'", () => { + const result = findRuffVersionInSpec("ruff>=0.14,<2.0,!=1.5.0"); + expect(result).toBe(">=0.14,<2.0,!=1.5.0"); + expect(warning).not.toHaveBeenCalled(); + }); + + it("returns undefined for non-ruff dependencies", () => { + const result = findRuffVersionInSpec("another-dep==0.1.6"); + expect(result).toBeUndefined(); + expect(info).not.toHaveBeenCalled(); + expect(warning).not.toHaveBeenCalled(); + }); + + it("strips trailing backslash", () => { + const result = findRuffVersionInSpec("ruff==0.9.3 \\"); + expect(result).toBe("0.9.3"); + expect(info).toHaveBeenCalledWith( + "Found ruff version in requirements file: 0.9.3", + ); + expect(warning).not.toHaveBeenCalled(); + }); + + it("strips environment markers and warns", () => { + const result = findRuffVersionInSpec( + 'ruff>=0.14 ; python_version >= "3.11"', + ); + expect(result).toBe(">=0.14"); + expect(info).toHaveBeenCalledWith( + "Found ruff version in requirements file: >=0.14", + ); + expect(warning).toHaveBeenCalledWith( + "Environment markers are ignored. ruff is a standalone tool that works independently of Python version.", + ); + }); + + it("handles whitespace", () => { + const result = findRuffVersionInSpec(" ruff >=0.14 "); + expect(result).toBe(">=0.14"); + expect(warning).not.toHaveBeenCalled(); + }); + + it("returns undefined for empty strings", () => { + const result = findRuffVersionInSpec(""); + expect(result).toBeUndefined(); + expect(info).not.toHaveBeenCalled(); + expect(warning).not.toHaveBeenCalled(); + }); + }); + + describe("getRuffVersionFromFile", () => { + it("reads the version from requirements.txt", () => { + const result = getRuffVersionFromFile( + "__tests__/fixtures/requirements.txt", + ); + expect(result).toBe("0.9.0"); + }); + + it("reads the version from requirements files with hashes", () => { + const result = getRuffVersionFromFile( + "__tests__/fixtures/requirements-with-hash.txt", + ); + expect(result).toBe("0.9.0"); + }); + + it("reads the version from pyproject.toml dependencies", () => { + const result = getRuffVersionFromFile( + "__tests__/fixtures/pyproject.toml", + ); + expect(result).toBe("0.9.3"); + }); + + it("reads the version from Poetry dependencies", () => { + const result = getRuffVersionFromFile( + "__tests__/fixtures/pyproject-dependency-poetry-project/pyproject.toml", + ); + expect(result).toBe("~0.8.2"); + }); + }); +}); diff --git a/__tests__/version/version-request-resolver.test.ts b/__tests__/version/version-request-resolver.test.ts new file mode 100644 index 0000000..87a8423 --- /dev/null +++ b/__tests__/version/version-request-resolver.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; + +const debug = jest.fn(); +const info = jest.fn(); +const warning = jest.fn(); + +jest.unstable_mockModule("@actions/core", () => ({ + debug, + info, + warning, +})); + +const { resolveVersionRequest } = await import( + "../../src/version/version-request-resolver" +); + +const tempDirs: string[] = []; + +function createTempProject(files: Record = {}): string { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), "ruff-action-version-test-"), + ); + tempDirs.push(dir); + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + } + + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe("resolveVersionRequest", () => { + beforeEach(() => { + debug.mockReset(); + info.mockReset(); + warning.mockReset(); + }); + + it("prefers explicit input over workspace discovery", () => { + const workspaceRoot = createTempProject({ + "pyproject.toml": `[project]\ndependencies = ["ruff==0.5.14"]\n`, + }); + + const request = resolveVersionRequest({ + sourceDirectory: workspaceRoot, + version: "==0.6.0", + workspaceRoot, + }); + + expect(request).toEqual({ + source: "input", + specifier: "0.6.0", + }); + }); + + it("uses requirements.txt when it is passed via version-file", () => { + const workspaceRoot = createTempProject({ + "requirements.txt": "ruff==0.6.17\nruff-api==0.1.0\n", + }); + + const request = resolveVersionRequest({ + sourceDirectory: workspaceRoot, + versionFile: path.join(workspaceRoot, "requirements.txt"), + workspaceRoot, + }); + + expect(request).toEqual({ + format: "requirements", + source: "version-file", + sourcePath: path.join(workspaceRoot, "requirements.txt"), + specifier: "0.6.17", + }); + }); + + it("warns and falls back to latest when version-file does not resolve a version", () => { + const workspaceRoot = createTempProject({ + "requirements.txt": "ruff-api==0.1.0\n", + }); + + const request = resolveVersionRequest({ + sourceDirectory: workspaceRoot, + versionFile: path.join(workspaceRoot, "requirements.txt"), + workspaceRoot, + }); + + expect(request).toEqual({ + source: "default", + specifier: "latest", + }); + expect(warning).toHaveBeenCalledWith( + `Could not parse version from ${path.join(workspaceRoot, "requirements.txt")}. Using latest version.`, + ); + }); + + it("discovers pyproject.toml by searching upward from src", () => { + const workspaceRoot = createTempProject({ + "pyproject.toml": `[project]\ndependencies = ["ruff==0.10.0"]\n`, + "subproject/nested/example.py": 'print("hello")\n', + }); + const sourceDirectory = path.join(workspaceRoot, "subproject", "nested"); + + const request = resolveVersionRequest({ + sourceDirectory, + workspaceRoot, + }); + + expect(request).toEqual({ + format: "pyproject.toml", + source: "pyproject.toml", + sourcePath: path.join(workspaceRoot, "pyproject.toml"), + specifier: "0.10.0", + }); + }); + + it("falls back to latest when no workspace version source is found", () => { + const workspaceRoot = createTempProject({ + "subproject/example.py": 'print("hello")\n', + }); + + const request = resolveVersionRequest({ + sourceDirectory: path.join(workspaceRoot, "subproject"), + workspaceRoot, + }); + + expect(request).toEqual({ + source: "default", + specifier: "latest", + }); + expect(info).toHaveBeenCalledWith( + "Could not find pyproject.toml. Using latest version.", + ); + }); + + it("throws when both version and version-file are specified", () => { + const workspaceRoot = createTempProject(); + + expect(() => + resolveVersionRequest({ + sourceDirectory: workspaceRoot, + version: "0.6.0", + versionFile: path.join(workspaceRoot, "requirements.txt"), + workspaceRoot, + }), + ).toThrow("It is not allowed to specify both version and version-file"); + }); +}); diff --git a/action.yml b/action.yml index 4e47051..c63a362 100644 --- a/action.yml +++ b/action.yml @@ -11,11 +11,11 @@ inputs: required: false default: ${{ github.workspace }} version: - description: "The version of Ruff to use, e.g., `0.6.0` Defaults to the version in pyproject.toml or 'latest'." + description: "The version of Ruff to use, e.g., `0.6.0`. Defaults to the first discoverable version in pyproject.toml searched upward from `src`, or `latest`." required: false default: "" version-file: - description: "Path to a pyproject.toml or requirements.txt file to read the version from." + description: "Path to a pyproject.toml or requirements.txt file to read the version from. If parsing fails, the action warns and falls back to `latest`." required: false checksum: description: "The checksum of the ruff version to install" diff --git a/dist/ruff-action/index.cjs b/dist/ruff-action/index.cjs index b9a859c..a4d5bed 100644 --- a/dist/ruff-action/index.cjs +++ b/dist/ruff-action/index.cjs @@ -24746,7 +24746,6 @@ function _getGlobal(key, defaultValue) { } // src/download/download-version.ts -var pep440 = __toESM(require_pep440(), 1); var semver3 = __toESM(require_semver(), 1); // src/utils/constants.ts @@ -28104,24 +28103,6 @@ async function extractDownloadedArtifact(version2, downloadPath, extension, plat debug(`Contents of ${ruffDir}: ${files.join(", ")}`); return ruffDir; } -async function resolveVersion(versionInput, manifestUrl) { - debug(`Resolving ${versionInput}...`); - const version2 = versionInput === "latest" ? await getLatestVersion(manifestUrl) : versionInput; - if (isExplicitVersion(version2)) { - debug(`Version ${version2} is an explicit version.`); - return version2; - } - const availableVersions = await getAvailableVersions(manifestUrl); - const resolvedVersion = maxSatisfying2(availableVersions, version2); - if (resolvedVersion === void 0) { - throw new Error(`No version found for ${version2}`); - } - debug(`Resolved version: ${resolvedVersion}`); - return resolvedVersion; -} -async function getAvailableVersions(manifestUrl) { - return await getAllVersions(manifestUrl); -} function getMissingArtifactMessage(version2, arch3, platform2, manifestUrl) { if (manifestUrl === void 0) { return `Could not find artifact for version ${version2}, arch ${arch3}, platform ${platform2} in ${VERSIONS_MANIFEST_URL} .`; @@ -28147,21 +28128,6 @@ function stripVersionPrefix2(version2) { function getExtension(platform2) { return platform2 === "pc-windows-msvc" ? ".zip" : ".tar.gz"; } -function maxSatisfying2(versions, version2) { - const maxSemver = evaluateVersions(versions, version2); - if (maxSemver !== "") { - debug(`Found a version that satisfies the semver range: ${maxSemver}`); - return maxSemver; - } - const maxPep440 = pep440.maxSatisfying(versions, version2); - if (maxPep440 !== null) { - debug( - `Found a version that satisfies the pep440 specifier: ${maxPep440}` - ); - return maxPep440; - } - return void 0; -} // src/utils/inputs.ts var version = getInput("version"); @@ -28196,8 +28162,71 @@ function getPlatform() { } } -// src/utils/pyproject.ts +// src/version/resolve.ts +var pep440 = __toESM(require_pep440(), 1); + +// src/version/specifier.ts +function normalizeVersionSpecifier(specifier) { + const trimmedSpecifier = specifier.trim(); + if (trimmedSpecifier.startsWith("==")) { + return trimmedSpecifier.slice(2); + } + return trimmedSpecifier; +} +function parseVersionSpecifier(specifier) { + const raw = specifier.trim(); + const normalized = normalizeVersionSpecifier(raw); + if (normalized === "latest") { + return { + kind: "latest", + normalized: "latest", + raw + }; + } + if (isExplicitVersion(normalized)) { + return { + kind: "exact", + normalized, + raw + }; + } + return { + kind: "range", + normalized, + raw + }; +} + +// src/utils/pyproject-finder.ts var fs6 = __toESM(require("node:fs"), 1); +var path7 = __toESM(require("node:path"), 1); +function findPyprojectToml(startDir, workspaceRoot) { + let currentDir = path7.resolve(startDir); + const resolvedWorkspaceRoot = path7.resolve(workspaceRoot); + while (true) { + const pyprojectPath = path7.join(currentDir, "pyproject.toml"); + debug(`Checking for ${pyprojectPath}`); + if (fs6.existsSync(pyprojectPath)) { + info(`Found pyproject.toml at ${pyprojectPath}`); + return pyprojectPath; + } + if (currentDir === resolvedWorkspaceRoot) { + return void 0; + } + const parentDir = path7.dirname(currentDir); + if (parentDir === currentDir || !isPathWithinWorkspace(parentDir, resolvedWorkspaceRoot)) { + return void 0; + } + currentDir = parentDir; + } +} +function isPathWithinWorkspace(checkPath, workspaceRoot) { + const relativePath = path7.relative(workspaceRoot, checkPath); + return !relativePath.startsWith("..") && !path7.isAbsolute(relativePath); +} + +// src/version/file-parser.ts +var import_node_fs2 = __toESM(require("node:fs"), 1); // node_modules/smol-toml/dist/error.js function getLineColFromPtr(string, ptr) { @@ -28889,14 +28918,60 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) { return res; } -// src/utils/pyproject.ts +// src/version/file-parser.ts +var VERSION_FILE_PARSERS = [ + { + format: "pyproject.toml", + parse: (filePath) => { + const fileContent = import_node_fs2.default.readFileSync(filePath, "utf-8"); + return getRuffVersionFromPyprojectContent(fileContent); + }, + supports: (filePath) => filePath.endsWith("pyproject.toml") + }, + { + format: "requirements", + parse: (filePath) => { + const fileContent = import_node_fs2.default.readFileSync(filePath, "utf-8"); + return getRuffVersionFromRequirementsText(fileContent); + }, + supports: (filePath) => filePath.endsWith(".txt") + } +]; +function getParsedVersionFile(filePath) { + info(`Trying to find version for ruff in: ${filePath}`); + if (!import_node_fs2.default.existsSync(filePath)) { + warning(`Could not find file: ${filePath}`); + return void 0; + } + const parser = getVersionFileParser(filePath); + if (parser === void 0) { + return void 0; + } + try { + const specifier = parser.parse(filePath); + if (specifier === void 0) { + return void 0; + } + const normalizedSpecifier = normalizeVersionSpecifier(specifier); + info(`Found version for ruff in ${filePath}: ${normalizedSpecifier}`); + return { + format: parser.format, + specifier: normalizedSpecifier + }; + } catch (error2) { + warning( + `Error while parsing ${filePath}: ${error2.message}` + ); + return void 0; + } +} function findRuffVersionInSpec(spec) { const trimmedSpec = spec.trim(); - const fullDepMatch = trimmedSpec.match(/^ruff\s*(.+)$/); - let versionSpec; - if (fullDepMatch) { - versionSpec = fullDepMatch[1]; - } else { + if (!trimmedSpec.startsWith("ruff")) { + return void 0; + } + let versionSpec = trimmedSpec.slice("ruff".length); + if (!versionSpec.match(/^(?:\s+|[=<>~!])/)) { return void 0; } versionSpec = versionSpec.replace(/\\$/, "").trim(); @@ -28904,9 +28979,7 @@ function findRuffVersionInSpec(spec) { if (match) { let version2 = match[1].trim(); if (version2) { - if (version2.startsWith("==")) { - version2 = version2.slice(2); - } + version2 = normalizeVersionSpecifier(version2); if (trimmedSpec.includes(";")) { warning( "Environment markers are ignored. ruff is a standalone tool that works independently of Python version." @@ -28918,24 +28991,33 @@ function findRuffVersionInSpec(spec) { } return void 0; } -function getRuffVersionFromAllDependencies(allDependencies) { - return allDependencies.map((dep) => findRuffVersionInSpec(dep)).find((version2) => version2 !== void 0); +function getRuffVersionFromRequirementsText(fileContent) { + return getRuffVersionFromAllDependencies(fileContent.split("\n")); } -function parsePyproject(pyprojectContent) { - const pyproject = parse(pyprojectContent); - const dependencies = pyproject?.project?.dependencies || []; +function getRuffVersionFromPyprojectContent(pyprojectContent) { + const pyproject = parsePyprojectContent(pyprojectContent); + return getRuffVersionFromParsedPyproject(pyproject); +} +function parsePyprojectContent(pyprojectContent) { + return parse(pyprojectContent); +} +function getVersionFileParser(filePath) { + return VERSION_FILE_PARSERS.find((parser) => parser.supports(filePath)); +} +function getRuffVersionFromParsedPyproject(pyproject) { + const dependencies = pyproject.project?.dependencies || []; const optionalDependencies = Object.values( - pyproject?.project?.["optional-dependencies"] || {} + pyproject.project?.["optional-dependencies"] || {} ).flat(); const devDependencies = Object.values( - pyproject?.["dependency-groups"] || {} + pyproject["dependency-groups"] || {} ).flat().filter((item) => typeof item === "string"); return getRuffVersionFromAllDependencies( dependencies.concat(optionalDependencies, devDependencies) ) || getRuffVersionFromPoetryGroups(pyproject); } function getRuffVersionFromPoetryGroups(pyproject) { - const poetry = pyproject?.tool?.poetry || {}; + const poetry = pyproject.tool?.poetry || {}; const poetryGroups = Object.values(poetry.group || {}); if (poetry.dependencies) { poetryGroups.unshift({ dependencies: poetry.dependencies }); @@ -28947,50 +29029,203 @@ function getRuffVersionFromPoetryGroups(pyproject) { return void 0; }).find((version2) => version2 !== void 0); } -function getRuffVersionFromRequirementsFile(filePath) { - if (!fs6.existsSync(filePath)) { - warning(`Could not find file: ${filePath}`); - return void 0; - } - const pyprojectContent = fs6.readFileSync(filePath, "utf-8"); - if (filePath.endsWith(".txt")) { - return getRuffVersionFromAllDependencies(pyprojectContent.split("\n")); - } - try { - return parsePyproject(pyprojectContent); - } catch (err) { - const message = err.message; - warning(`Error while parsing ${filePath}: ${message}`); - return void 0; - } +function getRuffVersionFromAllDependencies(allDependencies) { + return allDependencies.map((dependency) => findRuffVersionInSpec(dependency)).find((version2) => version2 !== void 0); } -// src/utils/pyproject-finder.ts -var fs7 = __toESM(require("node:fs"), 1); -var path7 = __toESM(require("node:path"), 1); -function findPyprojectToml(startDir, workspaceRoot) { - let currentDir = path7.resolve(startDir); - const resolvedWorkspaceRoot = path7.resolve(workspaceRoot); - while (true) { - const pyprojectPath = path7.join(currentDir, "pyproject.toml"); - debug(`Checking for ${pyprojectPath}`); - if (fs7.existsSync(pyprojectPath)) { - info(`Found pyproject.toml at ${pyprojectPath}`); - return pyprojectPath; - } - if (currentDir === resolvedWorkspaceRoot) { - return void 0; - } - const parentDir = path7.dirname(currentDir); - if (parentDir === currentDir || !isPathWithinWorkspace(parentDir, resolvedWorkspaceRoot)) { - return void 0; - } - currentDir = parentDir; +// src/version/version-request-resolver.ts +var VersionRequestContext = class { + sourceDirectory; + version; + versionFile; + workspaceRoot; + parsedFiles = /* @__PURE__ */ new Map(); + constructor(version2, versionFile2, sourceDirectory, workspaceRoot) { + this.version = version2; + this.versionFile = versionFile2; + this.sourceDirectory = sourceDirectory; + this.workspaceRoot = workspaceRoot; } + getVersionFile(filePath) { + const cachedResult = this.parsedFiles.get(filePath); + if (cachedResult !== void 0 || this.parsedFiles.has(filePath)) { + return cachedResult; + } + const result = getParsedVersionFile(filePath); + this.parsedFiles.set(filePath, result); + return result; + } + getWorkspacePyprojectPath() { + return findPyprojectToml(this.sourceDirectory, this.workspaceRoot); + } +}; +var ExplicitInputVersionResolver = class { + resolve(context) { + if (context.version === void 0) { + return void 0; + } + return { + source: "input", + specifier: normalizeVersionSpecifier(context.version) + }; + } +}; +var VersionFileVersionResolver = class { + resolve(context) { + if (context.versionFile === void 0) { + return void 0; + } + const versionFile2 = context.getVersionFile(context.versionFile); + if (versionFile2 === void 0) { + warning( + `Could not parse version from ${context.versionFile}. Using latest version.` + ); + return void 0; + } + return { + format: versionFile2.format, + source: "version-file", + sourcePath: context.versionFile, + specifier: versionFile2.specifier + }; + } +}; +var WorkspaceVersionResolver = class { + resolve(context) { + const pyprojectPath = context.getWorkspacePyprojectPath(); + if (!pyprojectPath) { + info("Could not find pyproject.toml. Using latest version."); + return void 0; + } + const versionFile2 = context.getVersionFile(pyprojectPath); + if (versionFile2 === void 0) { + info( + `Could not parse version from ${pyprojectPath}. Using latest version.` + ); + return void 0; + } + return { + format: versionFile2.format, + source: "pyproject.toml", + sourcePath: pyprojectPath, + specifier: versionFile2.specifier + }; + } +}; +var LatestVersionResolver = class { + resolve() { + return { + source: "default", + specifier: "latest" + }; + } +}; +var VERSION_REQUEST_RESOLVERS = [ + new ExplicitInputVersionResolver(), + new VersionFileVersionResolver(), + new WorkspaceVersionResolver(), + new LatestVersionResolver() +]; +function resolveVersionRequest(options) { + const version2 = emptyToUndefined(options.version); + const versionFile2 = emptyToUndefined(options.versionFile); + if (version2 !== void 0 && versionFile2 !== void 0) { + throw new Error( + "It is not allowed to specify both version and version-file" + ); + } + const context = new VersionRequestContext( + version2, + versionFile2, + options.sourceDirectory, + options.workspaceRoot + ); + for (const resolver of VERSION_REQUEST_RESOLVERS) { + const request = resolver.resolve(context); + if (request !== void 0) { + return request; + } + } + throw new Error("Could not resolve a requested Ruff version."); } -function isPathWithinWorkspace(checkPath, workspaceRoot) { - const relativePath = path7.relative(workspaceRoot, checkPath); - return !relativePath.startsWith("..") && !path7.isAbsolute(relativePath); +function emptyToUndefined(value) { + return value === void 0 || value === "" ? void 0 : value; +} + +// src/version/resolve.ts +var ExactVersionResolver = class { + async resolve(context) { + if (context.parsedSpecifier.kind !== "exact") { + return void 0; + } + debug( + `Version ${context.parsedSpecifier.normalized} is an explicit version.` + ); + return context.parsedSpecifier.normalized; + } +}; +var LatestVersionResolver2 = class { + async resolve(context) { + if (context.parsedSpecifier.kind !== "latest") { + return void 0; + } + return await getLatestVersion(context.manifestUrl); + } +}; +var RangeVersionResolver = class { + async resolve(context) { + if (context.parsedSpecifier.kind !== "range") { + return void 0; + } + const availableVersions = await getAllVersions(context.manifestUrl); + const resolvedVersion = maxSatisfying2( + availableVersions, + context.parsedSpecifier.normalized + ); + if (resolvedVersion === void 0) { + throw new Error(`No version found for ${context.parsedSpecifier.raw}`); + } + debug(`Resolved version: ${resolvedVersion}`); + return resolvedVersion; + } +}; +var CONCRETE_VERSION_RESOLVERS = [ + new ExactVersionResolver(), + new LatestVersionResolver2(), + new RangeVersionResolver() +]; +async function resolveRuffVersion(options) { + const request = resolveVersionRequest(options); + return await resolveVersion(request.specifier, options.manifestFile); +} +async function resolveVersion(versionInput, manifestUrl) { + debug(`Resolving ${versionInput}...`); + const context = { + manifestUrl, + parsedSpecifier: parseVersionSpecifier(versionInput) + }; + for (const resolver of CONCRETE_VERSION_RESOLVERS) { + const version2 = await resolver.resolve(context); + if (version2 !== void 0) { + return version2; + } + } + throw new Error(`No version found for ${versionInput}`); +} +function maxSatisfying2(versions, version2) { + const maxSemver = evaluateVersions(versions, version2); + if (maxSemver !== "") { + debug(`Found a version that satisfies the semver range: ${maxSemver}`); + return maxSemver; + } + const maxPep440 = pep440.maxSatisfying(versions, version2); + if (maxPep440 !== null) { + debug( + `Found a version that satisfies the pep440 specifier: ${maxPep440}` + ); + return maxPep440; + } + return void 0; } // src/ruff-action.ts @@ -29050,42 +29285,13 @@ async function setupRuff(platform2, arch3, checkSum2, githubToken2) { }; } async function determineVersion() { - if (versionFile !== "" && version !== "") { - throw Error("It is not allowed to specify both version and version-file"); - } - if (version !== "") { - return await resolveVersion(version, manifestFile || void 0); - } - if (versionFile !== "") { - const versionFromPyproject2 = getRuffVersionFromRequirementsFile(versionFile); - if (versionFromPyproject2 === void 0) { - warning( - `Could not parse version from ${versionFile}. Using latest version.` - ); - } - return await resolveVersion( - versionFromPyproject2 || "latest", - manifestFile || void 0 - ); - } - const pyProjectPath = findPyprojectToml( - src, - process.env.GITHUB_WORKSPACE || "." - ); - if (!pyProjectPath) { - info(`Could not find pyproject.toml. Using latest version.`); - return await resolveVersion("latest", manifestFile || void 0); - } - const versionFromPyproject = getRuffVersionFromRequirementsFile(pyProjectPath); - if (versionFromPyproject === void 0) { - info( - `Could not parse version from ${pyProjectPath}. Using latest version.` - ); - } - return await resolveVersion( - versionFromPyproject || "latest", - manifestFile || void 0 - ); + return await resolveRuffVersion({ + manifestFile: manifestFile || void 0, + sourceDirectory: src, + version, + versionFile, + workspaceRoot: process.env.GITHUB_WORKSPACE || "." + }); } function addRuffToPath(cachedPath) { addPath(cachedPath); diff --git a/src/download/download-version.ts b/src/download/download-version.ts index 283cef7..c5f68aa 100644 --- a/src/download/download-version.ts +++ b/src/download/download-version.ts @@ -2,7 +2,6 @@ import { promises as fs } from "node:fs"; import * as path from "node:path"; import * as core from "@actions/core"; import * as tc from "@actions/tool-cache"; -import * as pep440 from "@renovatebot/pep440"; import * as semver from "semver"; import { ASTRAL_MIRROR_PREFIX, @@ -12,7 +11,7 @@ import { } from "../utils/constants"; import type { Architecture, Platform } from "../utils/platforms"; import { validateChecksum } from "./checksum/checksum"; -import { getAllVersions, getArtifact, getLatestVersion } from "./manifest"; +import { getArtifact } from "./manifest"; export function tryGetFromToolCache( arch: Architecture, @@ -162,35 +161,6 @@ async function extractDownloadedArtifact( return ruffDir; } -export async function resolveVersion( - versionInput: string, - manifestUrl?: string, -): Promise { - core.debug(`Resolving ${versionInput}...`); - - const version = - versionInput === "latest" - ? await getLatestVersion(manifestUrl) - : versionInput; - - if (tc.isExplicitVersion(version)) { - core.debug(`Version ${version} is an explicit version.`); - return version; - } - - const availableVersions = await getAvailableVersions(manifestUrl); - const resolvedVersion = maxSatisfying(availableVersions, version); - if (resolvedVersion === undefined) { - throw new Error(`No version found for ${version}`); - } - core.debug(`Resolved version: ${resolvedVersion}`); - return resolvedVersion; -} - -async function getAvailableVersions(manifestUrl?: string): Promise { - return await getAllVersions(manifestUrl); -} - function getMissingArtifactMessage( version: string, arch: Architecture, @@ -245,22 +215,3 @@ function stripVersionPrefix(version: string): string { function getExtension(platform: Platform): string { return platform === "pc-windows-msvc" ? ".zip" : ".tar.gz"; } - -function maxSatisfying( - versions: string[], - version: string, -): string | undefined { - const maxSemver = tc.evaluateVersions(versions, version); - if (maxSemver !== "") { - core.debug(`Found a version that satisfies the semver range: ${maxSemver}`); - return maxSemver; - } - const maxPep440 = pep440.maxSatisfying(versions, version); - if (maxPep440 !== null) { - core.debug( - `Found a version that satisfies the pep440 specifier: ${maxPep440}`, - ); - return maxPep440; - } - return undefined; -} diff --git a/src/ruff-action.ts b/src/ruff-action.ts index 68d873a..38f610e 100644 --- a/src/ruff-action.ts +++ b/src/ruff-action.ts @@ -4,7 +4,6 @@ import * as exec from "@actions/exec"; import * as semver from "semver"; import { downloadVersion, - resolveVersion, tryGetFromToolCache, } from "./download/download-version"; import { @@ -22,8 +21,7 @@ import { getPlatform, type Platform, } from "./utils/platforms"; -import { getRuffVersionFromRequirementsFile } from "./utils/pyproject"; -import { findPyprojectToml } from "./utils/pyproject-finder"; +import { resolveRuffVersion } from "./version/resolve"; async function run(): Promise { const platform = getPlatform(); @@ -94,44 +92,13 @@ async function setupRuff( } async function determineVersion(): Promise { - if (versionFileInput !== "" && version !== "") { - throw Error("It is not allowed to specify both version and version-file"); - } - if (version !== "") { - return await resolveVersion(version, manifestFile || undefined); - } - if (versionFileInput !== "") { - const versionFromPyproject = - getRuffVersionFromRequirementsFile(versionFileInput); - if (versionFromPyproject === undefined) { - core.warning( - `Could not parse version from ${versionFileInput}. Using latest version.`, - ); - } - return await resolveVersion( - versionFromPyproject || "latest", - manifestFile || undefined, - ); - } - const pyProjectPath = findPyprojectToml( - src, - process.env.GITHUB_WORKSPACE || ".", - ); - if (!pyProjectPath) { - core.info(`Could not find pyproject.toml. Using latest version.`); - return await resolveVersion("latest", manifestFile || undefined); - } - const versionFromPyproject = - getRuffVersionFromRequirementsFile(pyProjectPath); - if (versionFromPyproject === undefined) { - core.info( - `Could not parse version from ${pyProjectPath}. Using latest version.`, - ); - } - return await resolveVersion( - versionFromPyproject || "latest", - manifestFile || undefined, - ); + return await resolveRuffVersion({ + manifestFile: manifestFile || undefined, + sourceDirectory: src, + version, + versionFile: versionFileInput, + workspaceRoot: process.env.GITHUB_WORKSPACE || ".", + }); } function addRuffToPath(cachedPath: string): void { diff --git a/src/utils/pyproject.ts b/src/utils/pyproject.ts deleted file mode 100644 index fbcacf8..0000000 --- a/src/utils/pyproject.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as fs from "node:fs"; -import * as core from "@actions/core"; -import * as toml from "smol-toml"; - -/** - * Find ruff version in a dependency specification. - * Only handles strings that start with "ruff" (e.g., "ruff==0.9.3"). - * Returns undefined for non-ruff dependencies. - * Strips environment markers (everything after ';'). - * Strips leading '==' from exact version specifiers (PEP 440) for downstream compatibility. - * - * @internal This is exported for testing purposes only. - */ -export function findRuffVersionInSpec(spec: string): string | undefined { - const trimmedSpec = spec.trim(); - - const fullDepMatch = trimmedSpec.match(/^ruff\s*(.+)$/); - let versionSpec: string; - if (fullDepMatch) { - versionSpec = fullDepMatch[1]; - } else { - return undefined; - } - - // Strip trailing backslash (line continuation) - versionSpec = versionSpec.replace(/\\$/, "").trim(); - - // Strip environment markers (everything after ';') - const match = versionSpec.match(/^([^;]+)(?:;.*)?$/); - - if (match) { - let version = match[1].trim(); - if (version) { - // Strip leading '==' from exact version specifiers for compatibility with semver - if (version.startsWith("==")) { - version = version.slice(2); - } - if (trimmedSpec.includes(";")) { - core.warning( - "Environment markers are ignored. ruff is a standalone tool that works independently of Python version.", - ); - } - core.info(`Found ruff version in requirements file: ${version}`); - return version; - } - } - - return undefined; -} - -function getRuffVersionFromAllDependencies( - allDependencies: string[], -): string | undefined { - return allDependencies - .map((dep) => findRuffVersionInSpec(dep)) - .find((version) => version !== undefined); -} - -interface Pyproject { - project?: { - dependencies?: string[]; - "optional-dependencies"?: Record; - }; - "dependency-groups"?: Record>; - tool?: { - poetry?: { - dependencies?: Record; - group?: Record }>; - }; - }; -} - -function parsePyproject(pyprojectContent: string): string | undefined { - const pyproject: Pyproject = toml.parse(pyprojectContent); - const dependencies: string[] = pyproject?.project?.dependencies || []; - const optionalDependencies: string[] = Object.values( - pyproject?.project?.["optional-dependencies"] || {}, - ).flat(); - const devDependencies: string[] = Object.values( - pyproject?.["dependency-groups"] || {}, - ) - .flat() - .filter((item: string | object) => typeof item === "string"); - return ( - getRuffVersionFromAllDependencies( - dependencies.concat(optionalDependencies, devDependencies), - ) || getRuffVersionFromPoetryGroups(pyproject) - ); -} - -function getRuffVersionFromPoetryGroups( - pyproject: Pyproject, -): string | undefined { - // Special handling for Poetry until it supports PEP 735 - // See: - const poetry = pyproject?.tool?.poetry || {}; - const poetryGroups = Object.values(poetry.group || {}); - if (poetry.dependencies) { - poetryGroups.unshift({ dependencies: poetry.dependencies }); - } - return poetryGroups - .flatMap((group) => Object.entries(group.dependencies)) - .map(([name, spec]) => { - if (typeof spec === "string") { - return findRuffVersionInSpec(`${name} ${spec}`); - } - return undefined; - }) - .find((version) => version !== undefined); -} - -export function getRuffVersionFromRequirementsFile( - filePath: string, -): string | undefined { - if (!fs.existsSync(filePath)) { - core.warning(`Could not find file: ${filePath}`); - return undefined; - } - const pyprojectContent = fs.readFileSync(filePath, "utf-8"); - if (filePath.endsWith(".txt")) { - return getRuffVersionFromAllDependencies(pyprojectContent.split("\n")); - } - try { - return parsePyproject(pyprojectContent); - } catch (err) { - const message = (err as Error).message; - core.warning(`Error while parsing ${filePath}: ${message}`); - return undefined; - } -} diff --git a/src/version/file-parser.ts b/src/version/file-parser.ts new file mode 100644 index 0000000..5f9a773 --- /dev/null +++ b/src/version/file-parser.ts @@ -0,0 +1,185 @@ +import fs from "node:fs"; +import * as core from "@actions/core"; +import * as toml from "smol-toml"; +import { normalizeVersionSpecifier } from "./specifier"; +import type { ParsedVersionFile, VersionFileFormat } from "./types"; + +interface VersionFileParser { + format: VersionFileFormat; + parse(filePath: string): string | undefined; + supports(filePath: string): boolean; +} + +interface Pyproject { + project?: { + dependencies?: string[]; + "optional-dependencies"?: Record; + }; + "dependency-groups"?: Record>; + tool?: { + poetry?: { + dependencies?: Record; + group?: Record }>; + }; + }; +} + +const VERSION_FILE_PARSERS: VersionFileParser[] = [ + { + format: "pyproject.toml", + parse: (filePath) => { + const fileContent = fs.readFileSync(filePath, "utf-8"); + return getRuffVersionFromPyprojectContent(fileContent); + }, + supports: (filePath) => filePath.endsWith("pyproject.toml"), + }, + { + format: "requirements", + parse: (filePath) => { + const fileContent = fs.readFileSync(filePath, "utf-8"); + return getRuffVersionFromRequirementsText(fileContent); + }, + supports: (filePath) => filePath.endsWith(".txt"), + }, +]; + +export function getParsedVersionFile( + filePath: string, +): ParsedVersionFile | undefined { + core.info(`Trying to find version for ruff in: ${filePath}`); + + if (!fs.existsSync(filePath)) { + core.warning(`Could not find file: ${filePath}`); + return undefined; + } + + const parser = getVersionFileParser(filePath); + if (parser === undefined) { + return undefined; + } + + try { + const specifier = parser.parse(filePath); + if (specifier === undefined) { + return undefined; + } + + const normalizedSpecifier = normalizeVersionSpecifier(specifier); + core.info(`Found version for ruff in ${filePath}: ${normalizedSpecifier}`); + return { + format: parser.format, + specifier: normalizedSpecifier, + }; + } catch (error) { + core.warning( + `Error while parsing ${filePath}: ${(error as Error).message}`, + ); + return undefined; + } +} + +export function getRuffVersionFromFile(filePath: string): string | undefined { + return getParsedVersionFile(filePath)?.specifier; +} + +export function findRuffVersionInSpec(spec: string): string | undefined { + const trimmedSpec = spec.trim(); + + if (!trimmedSpec.startsWith("ruff")) { + return undefined; + } + + let versionSpec = trimmedSpec.slice("ruff".length); + if (!versionSpec.match(/^(?:\s+|[=<>~!])/)) { + return undefined; + } + + versionSpec = versionSpec.replace(/\\$/, "").trim(); + + const match = versionSpec.match(/^([^;]+)(?:;.*)?$/); + + if (match) { + let version = match[1].trim(); + if (version) { + version = normalizeVersionSpecifier(version); + if (trimmedSpec.includes(";")) { + core.warning( + "Environment markers are ignored. ruff is a standalone tool that works independently of Python version.", + ); + } + core.info(`Found ruff version in requirements file: ${version}`); + return version; + } + } + + return undefined; +} + +export function getRuffVersionFromRequirementsText( + fileContent: string, +): string | undefined { + return getRuffVersionFromAllDependencies(fileContent.split("\n")); +} + +export function getRuffVersionFromPyprojectContent( + pyprojectContent: string, +): string | undefined { + const pyproject = parsePyprojectContent(pyprojectContent); + return getRuffVersionFromParsedPyproject(pyproject); +} + +export function parsePyprojectContent(pyprojectContent: string): Pyproject { + return toml.parse(pyprojectContent) as Pyproject; +} + +function getVersionFileParser(filePath: string): VersionFileParser | undefined { + return VERSION_FILE_PARSERS.find((parser) => parser.supports(filePath)); +} + +function getRuffVersionFromParsedPyproject( + pyproject: Pyproject, +): string | undefined { + const dependencies: string[] = pyproject.project?.dependencies || []; + const optionalDependencies: string[] = Object.values( + pyproject.project?.["optional-dependencies"] || {}, + ).flat(); + const devDependencies: string[] = Object.values( + pyproject["dependency-groups"] || {}, + ) + .flat() + .filter((item: string | object) => typeof item === "string"); + + return ( + getRuffVersionFromAllDependencies( + dependencies.concat(optionalDependencies, devDependencies), + ) || getRuffVersionFromPoetryGroups(pyproject) + ); +} + +function getRuffVersionFromPoetryGroups( + pyproject: Pyproject, +): string | undefined { + const poetry = pyproject.tool?.poetry || {}; + const poetryGroups = Object.values(poetry.group || {}); + if (poetry.dependencies) { + poetryGroups.unshift({ dependencies: poetry.dependencies }); + } + + return poetryGroups + .flatMap((group) => Object.entries(group.dependencies)) + .map(([name, spec]) => { + if (typeof spec === "string") { + return findRuffVersionInSpec(`${name} ${spec}`); + } + return undefined; + }) + .find((version) => version !== undefined); +} + +function getRuffVersionFromAllDependencies( + allDependencies: string[], +): string | undefined { + return allDependencies + .map((dependency) => findRuffVersionInSpec(dependency)) + .find((version) => version !== undefined); +} diff --git a/src/version/resolve.ts b/src/version/resolve.ts new file mode 100644 index 0000000..578bcf9 --- /dev/null +++ b/src/version/resolve.ts @@ -0,0 +1,125 @@ +import * as core from "@actions/core"; +import * as tc from "@actions/tool-cache"; +import * as pep440 from "@renovatebot/pep440"; +import { getAllVersions, getLatestVersion } from "../download/manifest"; +import { + type ParsedVersionSpecifier, + parseVersionSpecifier, +} from "./specifier"; +import type { ResolveRuffVersionOptions } from "./types"; +import { resolveVersionRequest } from "./version-request-resolver"; + +interface ConcreteVersionResolutionContext { + manifestUrl?: string; + parsedSpecifier: ParsedVersionSpecifier; +} + +interface ConcreteVersionResolver { + resolve( + context: ConcreteVersionResolutionContext, + ): Promise; +} + +class ExactVersionResolver implements ConcreteVersionResolver { + async resolve( + context: ConcreteVersionResolutionContext, + ): Promise { + if (context.parsedSpecifier.kind !== "exact") { + return undefined; + } + + core.debug( + `Version ${context.parsedSpecifier.normalized} is an explicit version.`, + ); + return context.parsedSpecifier.normalized; + } +} + +class LatestVersionResolver implements ConcreteVersionResolver { + async resolve( + context: ConcreteVersionResolutionContext, + ): Promise { + if (context.parsedSpecifier.kind !== "latest") { + return undefined; + } + + return await getLatestVersion(context.manifestUrl); + } +} + +class RangeVersionResolver implements ConcreteVersionResolver { + async resolve( + context: ConcreteVersionResolutionContext, + ): Promise { + if (context.parsedSpecifier.kind !== "range") { + return undefined; + } + + const availableVersions = await getAllVersions(context.manifestUrl); + const resolvedVersion = maxSatisfying( + availableVersions, + context.parsedSpecifier.normalized, + ); + if (resolvedVersion === undefined) { + throw new Error(`No version found for ${context.parsedSpecifier.raw}`); + } + + core.debug(`Resolved version: ${resolvedVersion}`); + return resolvedVersion; + } +} + +const CONCRETE_VERSION_RESOLVERS: ConcreteVersionResolver[] = [ + new ExactVersionResolver(), + new LatestVersionResolver(), + new RangeVersionResolver(), +]; + +export async function resolveRuffVersion( + options: ResolveRuffVersionOptions, +): Promise { + const request = resolveVersionRequest(options); + return await resolveVersion(request.specifier, options.manifestFile); +} + +export async function resolveVersion( + versionInput: string, + manifestUrl?: string, +): Promise { + core.debug(`Resolving ${versionInput}...`); + + const context: ConcreteVersionResolutionContext = { + manifestUrl, + parsedSpecifier: parseVersionSpecifier(versionInput), + }; + + for (const resolver of CONCRETE_VERSION_RESOLVERS) { + const version = await resolver.resolve(context); + if (version !== undefined) { + return version; + } + } + + throw new Error(`No version found for ${versionInput}`); +} + +function maxSatisfying( + versions: string[], + version: string, +): string | undefined { + const maxSemver = tc.evaluateVersions(versions, version); + if (maxSemver !== "") { + core.debug(`Found a version that satisfies the semver range: ${maxSemver}`); + return maxSemver; + } + + const maxPep440 = pep440.maxSatisfying(versions, version); + if (maxPep440 !== null) { + core.debug( + `Found a version that satisfies the pep440 specifier: ${maxPep440}`, + ); + return maxPep440; + } + + return undefined; +} diff --git a/src/version/specifier.ts b/src/version/specifier.ts new file mode 100644 index 0000000..25d595d --- /dev/null +++ b/src/version/specifier.ts @@ -0,0 +1,57 @@ +import * as tc from "@actions/tool-cache"; + +export type ParsedVersionSpecifier = + | { + kind: "exact"; + normalized: string; + raw: string; + } + | { + kind: "latest"; + normalized: "latest"; + raw: string; + } + | { + kind: "range"; + normalized: string; + raw: string; + }; + +export function normalizeVersionSpecifier(specifier: string): string { + const trimmedSpecifier = specifier.trim(); + + if (trimmedSpecifier.startsWith("==")) { + return trimmedSpecifier.slice(2); + } + + return trimmedSpecifier; +} + +export function parseVersionSpecifier( + specifier: string, +): ParsedVersionSpecifier { + const raw = specifier.trim(); + const normalized = normalizeVersionSpecifier(raw); + + if (normalized === "latest") { + return { + kind: "latest", + normalized: "latest", + raw, + }; + } + + if (tc.isExplicitVersion(normalized)) { + return { + kind: "exact", + normalized, + raw, + }; + } + + return { + kind: "range", + normalized, + raw, + }; +} diff --git a/src/version/types.ts b/src/version/types.ts new file mode 100644 index 0000000..11e7ece --- /dev/null +++ b/src/version/types.ts @@ -0,0 +1,27 @@ +export type VersionSource = + | "input" + | "version-file" + | "pyproject.toml" + | "default"; + +export type VersionFileFormat = "pyproject.toml" | "requirements"; + +export interface ParsedVersionFile { + format: VersionFileFormat; + specifier: string; +} + +export interface ResolveRuffVersionOptions { + manifestFile?: string; + sourceDirectory: string; + version?: string; + versionFile?: string; + workspaceRoot: string; +} + +export interface VersionRequest { + format?: VersionFileFormat; + source: VersionSource; + sourcePath?: string; + specifier: string; +} diff --git a/src/version/version-request-resolver.ts b/src/version/version-request-resolver.ts new file mode 100644 index 0000000..e3544c3 --- /dev/null +++ b/src/version/version-request-resolver.ts @@ -0,0 +1,162 @@ +import * as core from "@actions/core"; +import { findPyprojectToml } from "../utils/pyproject-finder"; +import { getParsedVersionFile } from "./file-parser"; +import { normalizeVersionSpecifier } from "./specifier"; +import type { + ParsedVersionFile, + ResolveRuffVersionOptions, + VersionRequest, +} from "./types"; + +export interface VersionRequestResolver { + resolve(context: VersionRequestContext): VersionRequest | undefined; +} + +export class VersionRequestContext { + readonly sourceDirectory: string; + readonly version: string | undefined; + readonly versionFile: string | undefined; + readonly workspaceRoot: string; + + private readonly parsedFiles = new Map< + string, + ParsedVersionFile | undefined + >(); + + constructor( + version: string | undefined, + versionFile: string | undefined, + sourceDirectory: string, + workspaceRoot: string, + ) { + this.version = version; + this.versionFile = versionFile; + this.sourceDirectory = sourceDirectory; + this.workspaceRoot = workspaceRoot; + } + + getVersionFile(filePath: string): ParsedVersionFile | undefined { + const cachedResult = this.parsedFiles.get(filePath); + if (cachedResult !== undefined || this.parsedFiles.has(filePath)) { + return cachedResult; + } + + const result = getParsedVersionFile(filePath); + this.parsedFiles.set(filePath, result); + return result; + } + + getWorkspacePyprojectPath(): string | undefined { + return findPyprojectToml(this.sourceDirectory, this.workspaceRoot); + } +} + +export class ExplicitInputVersionResolver implements VersionRequestResolver { + resolve(context: VersionRequestContext): VersionRequest | undefined { + if (context.version === undefined) { + return undefined; + } + + return { + source: "input", + specifier: normalizeVersionSpecifier(context.version), + }; + } +} + +export class VersionFileVersionResolver implements VersionRequestResolver { + resolve(context: VersionRequestContext): VersionRequest | undefined { + if (context.versionFile === undefined) { + return undefined; + } + + const versionFile = context.getVersionFile(context.versionFile); + if (versionFile === undefined) { + core.warning( + `Could not parse version from ${context.versionFile}. Using latest version.`, + ); + return undefined; + } + + return { + format: versionFile.format, + source: "version-file", + sourcePath: context.versionFile, + specifier: versionFile.specifier, + }; + } +} + +export class WorkspaceVersionResolver implements VersionRequestResolver { + resolve(context: VersionRequestContext): VersionRequest | undefined { + const pyprojectPath = context.getWorkspacePyprojectPath(); + if (!pyprojectPath) { + core.info("Could not find pyproject.toml. Using latest version."); + return undefined; + } + + const versionFile = context.getVersionFile(pyprojectPath); + if (versionFile === undefined) { + core.info( + `Could not parse version from ${pyprojectPath}. Using latest version.`, + ); + return undefined; + } + + return { + format: versionFile.format, + source: "pyproject.toml", + sourcePath: pyprojectPath, + specifier: versionFile.specifier, + }; + } +} + +export class LatestVersionResolver implements VersionRequestResolver { + resolve(): VersionRequest { + return { + source: "default", + specifier: "latest", + }; + } +} + +const VERSION_REQUEST_RESOLVERS: VersionRequestResolver[] = [ + new ExplicitInputVersionResolver(), + new VersionFileVersionResolver(), + new WorkspaceVersionResolver(), + new LatestVersionResolver(), +]; + +export function resolveVersionRequest( + options: ResolveRuffVersionOptions, +): VersionRequest { + const version = emptyToUndefined(options.version); + const versionFile = emptyToUndefined(options.versionFile); + + if (version !== undefined && versionFile !== undefined) { + throw new Error( + "It is not allowed to specify both version and version-file", + ); + } + + const context = new VersionRequestContext( + version, + versionFile, + options.sourceDirectory, + options.workspaceRoot, + ); + + for (const resolver of VERSION_REQUEST_RESOLVERS) { + const request = resolver.resolve(context); + if (request !== undefined) { + return request; + } + } + + throw new Error("Could not resolve a requested Ruff version."); +} + +function emptyToUndefined(value: string | undefined): string | undefined { + return value === undefined || value === "" ? undefined : value; +}