mirror of
https://github.com/astral-sh/ruff-action.git
synced 2026-09-19 10:51:30 +00:00
search in parent dir (#306)
Fixes: #164 --------- Co-authored-by: Clawdbot <clawdbot@users.noreply.github.com>
This commit is contained in:
co-authored by
Clawdbot
parent
1d756c4b80
commit
5eee2a4332
@@ -406,6 +406,25 @@ jobs:
|
||||
src: >-
|
||||
__tests__/fixtures/python-project/src/python_project/__init__.py
|
||||
__tests__/fixtures/python-project/src/python_project/hello_world.py
|
||||
test-parent-directory-pyproject:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use version from parent directory pyproject.toml
|
||||
id: ruff-action
|
||||
uses: ./
|
||||
with:
|
||||
src: __tests__/fixtures/parent-config-project/subproject
|
||||
- name: Correct version gets installed
|
||||
run: |
|
||||
if [ "$RUFF_VERSION" != "0.10.0" ]; then
|
||||
echo "Expected version 0.10.0 but got $RUFF_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }}
|
||||
|
||||
all-tests-passed:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -431,6 +450,7 @@ jobs:
|
||||
- test-args
|
||||
- test-failure
|
||||
- test-multiple-src
|
||||
- test-parent-directory-pyproject
|
||||
if: always()
|
||||
steps:
|
||||
- name: All tests passed
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "parent-config-project"
|
||||
version = "0.1.0"
|
||||
description = "Test fixture for parent directory pyproject.toml search"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"ruff==0.10.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Hello world example."""
|
||||
|
||||
print("Hello, world!")
|
||||
BIN
Binary file not shown.
+7
-4
@@ -1,4 +1,3 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
type Platform,
|
||||
} from "./utils/platforms";
|
||||
import { getRuffVersionFromRequirementsFile } from "./utils/pyproject";
|
||||
import { findPyprojectToml } from "./utils/pyproject-finder";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const platform = getPlatform();
|
||||
@@ -107,9 +107,12 @@ async function determineVersion(): Promise<string> {
|
||||
}
|
||||
return await resolveVersion(versionFromPyproject || "latest", githubToken);
|
||||
}
|
||||
const pyProjectPath = path.join(src, "pyproject.toml");
|
||||
if (!fs.existsSync(pyProjectPath)) {
|
||||
core.info(`Could not find ${pyProjectPath}. Using latest version.`);
|
||||
const pyProjectPath = findPyprojectToml(
|
||||
src,
|
||||
process.env.GITHUB_WORKSPACE || ".",
|
||||
);
|
||||
if (!pyProjectPath) {
|
||||
core.info(`Could not find pyproject.toml. Using latest version.`);
|
||||
return await resolveVersion("latest", githubToken);
|
||||
}
|
||||
const versionFromPyproject =
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as path from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import { findPyprojectToml } from "./pyproject-finder";
|
||||
|
||||
jest.mock("@actions/core", () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("findPyprojectToml", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when pyproject.toml exists in src directory", () => {
|
||||
it("should return the exact path", () => {
|
||||
const fixturesDir = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"__tests__",
|
||||
"fixtures",
|
||||
);
|
||||
const workspaceRoot = path.join(__dirname, "..", "..");
|
||||
|
||||
const result = findPyprojectToml(fixturesDir, workspaceRoot);
|
||||
|
||||
expect(result).toContain("pyproject.toml");
|
||||
expect(result).toContain("fixtures");
|
||||
expect(core.info).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when pyproject.toml exists only in parent directory", () => {
|
||||
it("should search upwards and find the parent's pyproject.toml", () => {
|
||||
// subproject doesn't have a pyproject.toml, but its parent (parent-config-project) does
|
||||
const subprojectDir = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"__tests__",
|
||||
"fixtures",
|
||||
"parent-config-project",
|
||||
"subproject",
|
||||
);
|
||||
const workspaceRoot = path.join(__dirname, "..", "..");
|
||||
|
||||
const result = findPyprojectToml(subprojectDir, workspaceRoot);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain("pyproject.toml");
|
||||
expect(result).toContain("parent-config-project");
|
||||
expect(core.info).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary conditions", () => {
|
||||
it("should stop searching at workspace root and return undefined when not found", () => {
|
||||
// Create a path that won't have pyproject.toml above it
|
||||
const nodeModulesDir = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"node_modules",
|
||||
"@actions",
|
||||
);
|
||||
const workspaceRoot = path.join(__dirname, "..", "..");
|
||||
|
||||
const result = findPyprojectToml(nodeModulesDir, workspaceRoot);
|
||||
|
||||
// Should return undefined since there's no pyproject.toml in the search path
|
||||
expect(result).toBeUndefined();
|
||||
expect(core.info).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Found pyproject.toml"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should find pyproject.toml when it exists at workspace root", () => {
|
||||
// Use parent-config-project as the "workspace root" for this test
|
||||
// Start from subproject (which has no pyproject.toml) to search up to workspace root
|
||||
const subprojectDir = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"__tests__",
|
||||
"fixtures",
|
||||
"parent-config-project",
|
||||
"subproject",
|
||||
);
|
||||
const workspaceRoot = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"__tests__",
|
||||
"fixtures",
|
||||
"parent-config-project",
|
||||
);
|
||||
|
||||
const result = findPyprojectToml(subprojectDir, workspaceRoot);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain("pyproject.toml");
|
||||
expect(result).toContain("parent-config-project");
|
||||
});
|
||||
|
||||
it("should stop at workspace root even if searching from it", () => {
|
||||
const workspaceRoot = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"__tests__",
|
||||
"fixtures",
|
||||
);
|
||||
|
||||
const result = findPyprojectToml(workspaceRoot, workspaceRoot);
|
||||
|
||||
// Should find pyproject.toml at workspace root
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain("pyproject.toml");
|
||||
expect(result).toContain("fixtures");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle relative paths", () => {
|
||||
const srcDir = "./__tests__/fixtures";
|
||||
const workspaceRoot = ".";
|
||||
|
||||
const result = findPyprojectToml(srcDir, workspaceRoot);
|
||||
|
||||
// Should work with relative paths
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain("pyproject.toml");
|
||||
});
|
||||
|
||||
it("should handle when src equals workspace root", () => {
|
||||
const workspaceRoot = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"__tests__",
|
||||
"fixtures",
|
||||
);
|
||||
const result = findPyprojectToml(workspaceRoot, workspaceRoot);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain("pyproject.toml");
|
||||
expect(result).toContain("fixtures");
|
||||
});
|
||||
|
||||
it("should log debug messages for each checked path", () => {
|
||||
const pythonProjectDir = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"__tests__",
|
||||
"fixtures",
|
||||
"python-project",
|
||||
);
|
||||
const workspaceRoot = path.join(__dirname, "..", "..");
|
||||
|
||||
findPyprojectToml(pythonProjectDir, workspaceRoot);
|
||||
|
||||
expect(core.debug).toHaveBeenCalled();
|
||||
const debugCalls = (core.debug as jest.Mock).mock.calls;
|
||||
expect(debugCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// First debug call should be for the starting directory
|
||||
expect(debugCalls[0][0]).toContain("Checking for");
|
||||
expect(debugCalls[0][0]).toContain("python-project");
|
||||
});
|
||||
|
||||
it("should handle paths with trailing slashes", () => {
|
||||
const fixturesDir = `${path.join(__dirname, "..", "..", "__tests__", "fixtures")}/`;
|
||||
const workspaceRoot = path.join(__dirname, "..", "..");
|
||||
|
||||
const result = findPyprojectToml(fixturesDir, workspaceRoot);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain("pyproject.toml");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
/**
|
||||
* Search for a pyproject.toml file starting from the given directory
|
||||
* and traversing upwards through parent directories until reaching
|
||||
* the GitHub workspace root.
|
||||
*
|
||||
* @param startDir The directory to start the search from (e.g., the src input)
|
||||
* @param workspaceRoot The GitHub workspace directory (GITHUB_WORKSPACE)
|
||||
* @returns The path to the found pyproject.toml, or undefined if not found
|
||||
*/
|
||||
export function findPyprojectToml(
|
||||
startDir: string,
|
||||
workspaceRoot: string,
|
||||
): string | undefined {
|
||||
let currentDir = path.resolve(startDir);
|
||||
const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
|
||||
|
||||
while (true) {
|
||||
const pyprojectPath = path.join(currentDir, "pyproject.toml");
|
||||
core.debug(`Checking for ${pyprojectPath}`);
|
||||
|
||||
if (fs.existsSync(pyprojectPath)) {
|
||||
core.info(`Found pyproject.toml at ${pyprojectPath}`);
|
||||
return pyprojectPath;
|
||||
}
|
||||
|
||||
// Check if we've reached the workspace root
|
||||
if (currentDir === resolvedWorkspaceRoot) {
|
||||
// If we're at workspace root and didn't find it, stop searching
|
||||
break;
|
||||
}
|
||||
|
||||
// Move up to parent directory
|
||||
const parentDir = path.dirname(currentDir);
|
||||
|
||||
// If parent is the same as current, we've reached the filesystem root
|
||||
if (parentDir === currentDir) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDir = parentDir;
|
||||
|
||||
// If we've gone past the workspace root, stop searching
|
||||
if (isPathWithinWorkspace(currentDir, resolvedWorkspaceRoot) === false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given path is within or equal to the workspace root.
|
||||
*
|
||||
* @param checkPath The path to check
|
||||
* @param workspaceRoot The workspace root directory
|
||||
* @returns true if within or equal to workspace, false if outside, undefined if can't determine
|
||||
*/
|
||||
function isPathWithinWorkspace(
|
||||
checkPath: string,
|
||||
workspaceRoot: string,
|
||||
): boolean | undefined {
|
||||
try {
|
||||
const checkPathResolved = path.resolve(checkPath);
|
||||
const workspaceRootResolved = path.resolve(workspaceRoot);
|
||||
|
||||
// Check if checkPath starts with workspaceRoot (case-insensitive on Windows)
|
||||
const relativePath = path.relative(
|
||||
workspaceRootResolved,
|
||||
checkPathResolved,
|
||||
);
|
||||
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user