diff --git a/.changeset/detect-python-dev-dependencies.md b/.changeset/detect-python-dev-dependencies.md new file mode 100644 index 00000000..74e4a35b --- /dev/null +++ b/.changeset/detect-python-dev-dependencies.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless detect` now finds a Python linter installed as a dev dependency in `pyproject.toml`, so a repository that runs ruff on its defaults, with no `[tool.ruff]` table, no longer reads as having no linter. It reads PEP 735 `[dependency-groups]` (what `uv add --dev` writes), `[tool.uv] dev-dependencies`, `[tool.pdm.dev-dependencies]`, Hatch environment dependencies, and Poetry dependency groups. Poetry groups were previously read by group name rather than by the dependencies inside them. diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 1bf8e077..100da90e 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -383,7 +383,35 @@ function requirementName(requirement: string): string { .toLowerCase(); } -/** Python dependency names declared in a parsed `pyproject.toml` (PEP 621 + Poetry). */ +/** Package names from an array of PEP 508 requirement strings. Non-string + * entries (a PEP 735 `{ include-group = "..." }`) and non-arrays contribute + * nothing. */ +function requirementNames(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => requirementName(entry)); +} + +/** Package names from a table of named groups, each an array of PEP 508 + * requirement strings (`optional-dependencies`, PEP 735 `dependency-groups`, + * PDM's `dev-dependencies`). */ +function groupedRequirementNames(value: unknown): string[] { + return Object.values(asRecord(value) ?? {}).flatMap((group) => + requirementNames(group) + ); +} + +/** + * Python dependency names declared in a parsed `pyproject.toml`. + * + * A dev tool like ruff is rarely a runtime dependency, so the dev-dependency + * locations matter as much as `[project]`: PEP 735 `[dependency-groups]` (what + * `uv add --dev` writes), uv's legacy `[tool.uv] dev-dependencies`, PDM's and + * Hatch's own tables, and Poetry's groups. A repository that installs ruff + * through any of these and runs it on its defaults has no `[tool.ruff]` table, + * so this is the only evidence it lints. + */ function pythonDepsFromPyproject( pyproject: Record | undefined ): string[] { @@ -391,26 +419,41 @@ function pythonDepsFromPyproject( const names: string[] = []; const project = asRecord(pyproject.project); - const projectDeps = project?.dependencies; - if (Array.isArray(projectDeps)) { - for (const entry of projectDeps) { - if (typeof entry === "string") names.push(requirementName(entry)); - } - } - const optional = asRecord(project?.["optional-dependencies"]); - for (const group of Object.values(optional ?? {})) { - if (!Array.isArray(group)) continue; - for (const entry of group) { - if (typeof entry === "string") names.push(requirementName(entry)); - } + names.push( + ...requirementNames(project?.dependencies), + ...groupedRequirementNames(project?.["optional-dependencies"]), + ...groupedRequirementNames(pyproject["dependency-groups"]) + ); + + const tool = asRecord(pyproject.tool); + names.push( + ...requirementNames(asRecord(tool?.uv)?.["dev-dependencies"]), + ...groupedRequirementNames(asRecord(tool?.pdm)?.["dev-dependencies"]) + ); + for (const environment of Object.values( + asRecord(asRecord(tool?.hatch)?.envs) ?? {} + )) { + const table = asRecord(environment); + names.push( + ...requirementNames(table?.dependencies), + ...requirementNames(table?.["extra-dependencies"]) + ); } - const poetry = asRecord(asRecord(pyproject.tool)?.poetry); - for (const key of ["dependencies", "dev-dependencies", "group"]) { - const table = asRecord(poetry?.[key]); - if (table) { - names.push(...Object.keys(table).map((name) => name.toLowerCase())); - } + // Poetry keys dependencies by name. Groups nest one level deeper, under + // `[tool.poetry.group..dependencies]`. + const poetry = asRecord(tool?.poetry); + const poetryTables = [ + poetry?.dependencies, + poetry?.["dev-dependencies"], + ...Object.values(asRecord(poetry?.group) ?? {}).map( + (group) => asRecord(group)?.dependencies + ), + ]; + for (const table of poetryTables) { + names.push( + ...Object.keys(asRecord(table) ?? {}).map((name) => name.toLowerCase()) + ); } return names; diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index d39e4530..c74a5e5f 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -204,6 +204,48 @@ describe("taskless detect", () => { expect(result.languages).toContain("Python"); }); + it.each([ + [ + "PEP 735 [dependency-groups]", + '[dependency-groups]\ndev = [{ include-group = "test" }, "ruff>=0.6"]\ntest = ["pytest"]\n', + ], + ["[tool.uv] dev-dependencies", '[tool.uv]\ndev-dependencies = ["ruff"]\n'], + [ + "[tool.pdm.dev-dependencies]", + '[tool.pdm.dev-dependencies]\nlint = ["ruff==0.6.0"]\n', + ], + [ + "a Hatch environment", + '[tool.hatch.envs.lint]\ndependencies = ["ruff"]\n', + ], + [ + "a Poetry dependency group", + '[tool.poetry.group.dev.dependencies]\nruff = "^0.6"\n', + ], + ])( + "detects ruff installed only as a dev dependency in %s", + async (_location, toml) => { + // No [tool.ruff] table: ruff running on its defaults is still a linter. + await writeFile( + join(cwd, "pyproject.toml"), + `[project]\nname = "x"\n\n${toml}`, + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("ruff"); + } + ); + + it("does not read a Poetry group's name as a dependency", async () => { + await writeFile( + join(cwd, "pyproject.toml"), + '[tool.poetry.group.ruff.dependencies]\npytest = "^8"\n', + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("ruff"); + }); + it("detects flake8 from a requirements.txt entry", async () => { await writeFile( join(cwd, "requirements.txt"),