Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/detect-python-dev-dependencies.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 62 additions & 19 deletions packages/cli/src/detect/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,34 +383,77 @@ 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<string, unknown> | undefined
): string[] {
if (pyproject === undefined) return [];
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.<name>.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;
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/test/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading