From c77abcb8ef2859edd711c3242e70db95da0c9d72 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 4 Jun 2026 09:06:25 +0000 Subject: [PATCH 1/3] Surface pyproject.toml [project.scripts] as runnable tasks Python projects detected a package manager (uv/poetry/pipenv) but never listed their PEP 621 [project.scripts] console entry points, so `runner` showed "no tasks" for a project full of declared scripts. Add a PyprojectScripts task source: parse [project.scripts] from pyproject.toml, surface each entry with its entry-point target as the description, and dispatch via the detected Python PM's run subcommand (uv run / poetry run / pipenv run). Honors an explicit Python-ecosystem --pm / [pm].python override, falling back to the detected PM. --- CHANGELOG.md | 8 +++ README.md | 1 + src/cmd/list.rs | 5 ++ src/cmd/run/dispatch.rs | 87 +++++++++++++++++++++++++++++- src/cmd/run/select.rs | 1 + src/cmd/why.rs | 1 + src/detect.rs | 73 ++++++++++++++++++++++++- src/schema/v1.rs | 1 + src/tool/pipenv.rs | 19 ++++++- src/tool/poetry.rs | 19 ++++++- src/tool/python.rs | 117 +++++++++++++++++++++++++++++++++++++++- src/tool/uv.rs | 30 ++++++++++- src/types.rs | 9 ++++ 13 files changed, 365 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 948318d..3bb346e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Added +- `pyproject.toml` `[project.scripts]` entry points (PEP 621 console + scripts) are now extracted as runnable tasks for Python projects. They + surface under the `pyproject.toml` source in `runner list` (with the + entry-point target shown as the description) and dispatch via the + detected Python package manager's `run` subcommand — `uv run `, + `poetry run `, or `pipenv run `. Previously a uv/poetry + project's declared scripts were invisible to `runner`, which detected + the package manager but listed no tasks. - AUR distribution channel. Two packages on the Arch User Repository: `runner-run-bin` (prebuilt binaries for `x86_64`, `aarch64`, `armv7h`) and `runner-run` (source build for `x86_64`, `aarch64`). `-bin` diff --git a/README.md b/README.md index 6ea2fce..6182e2d 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,7 @@ Taskfile bacon.toml mise.toml / .mise.toml Cargo aliases from .cargo/config.toml +pyproject.toml [project.scripts] (run via uv / poetry / pipenv) ``` It also understands monorepo/workspace context from: diff --git a/src/cmd/list.rs b/src/cmd/list.rs index a8cd3c1..99c3af9 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -190,6 +190,7 @@ fn render_tasks_grouped_rich( TaskSource::GoPackage, TaskSource::BaconToml, TaskSource::MiseToml, + TaskSource::PyprojectScripts, ]; for source in sources { let source_tasks = tasks_for_source(tasks, source); @@ -370,6 +371,7 @@ fn render_tasks_grouped_compact(tasks: &[&Task], stdout_is_terminal: bool) -> St TaskSource::GoPackage, TaskSource::BaconToml, TaskSource::MiseToml, + TaskSource::PyprojectScripts, ]; for source in sources { let source_tasks = tasks_for_source(tasks, source); @@ -472,6 +474,9 @@ fn source_path(source: TaskSource, root: &Path) -> Option { tool::files::find_first(root, tool::bacon::FILENAMES).filter(|path| path.is_file()) } TaskSource::MiseToml => tool::mise::find_file(root), + TaskSource::PyprojectScripts => { + tool::files::find_first(root, &["pyproject.toml"]).filter(|path| path.is_file()) + } }?; Some(path.canonicalize().unwrap_or(path)) diff --git a/src/cmd/run/dispatch.rs b/src/cmd/run/dispatch.rs index 63d8e0b..0a23ef4 100644 --- a/src/cmd/run/dispatch.rs +++ b/src/cmd/run/dispatch.rs @@ -22,7 +22,7 @@ use super::qualify::{ use super::select::select_task_entry; use crate::resolver::{ResolutionOverrides, ResolveError, Resolver}; use crate::tool; -use crate::types::{PackageManager, ProjectContext, Task, TaskSource}; +use crate::types::{Ecosystem, PackageManager, ProjectContext, Task, TaskSource}; /// Resolve `task` to a fully-configured [`Command`] without spawning it. /// @@ -282,9 +282,47 @@ fn build_run_command( } TaskSource::BaconToml => tool::bacon::run_cmd(&entry.name, args), TaskSource::MiseToml => tool::mise::run_cmd(&entry.name, args), + TaskSource::PyprojectScripts => { + let Some(pm) = resolve_python_pm(ctx, overrides) else { + bail!( + "no Python package manager detected to run {:?}; install uv, poetry, or pipenv", + entry.name, + ); + }; + match pm { + PackageManager::Uv => tool::uv::run_cmd(&entry.name, args), + PackageManager::Poetry => tool::poetry::run_cmd(&entry.name, args), + PackageManager::Pipenv => tool::pipenv::run_cmd(&entry.name, args), + other => bail!("{} cannot run pyproject scripts", other.label()), + } + } }) } +/// Pick the Python package manager that dispatches a `[project.scripts]` +/// entry: an explicit Python-ecosystem `--pm` / `RUNNER_PM` override +/// first, then a `[pm].python` `runner.toml` override, then the PM +/// detected for the project. A non-Python `--pm` (e.g. `--pm pnpm` in a +/// mixed repo) is ignored here rather than forced, falling through to the +/// detected Python PM. +fn resolve_python_pm( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, +) -> Option { + if let Some(o) = overrides.pm.as_ref() + && o.pm.ecosystem() == Ecosystem::Python + { + return Some(o.pm); + } + if let Some(o) = overrides.pm_by_ecosystem.get(&Ecosystem::Python) { + return Some(o.pm); + } + ctx.package_managers + .iter() + .copied() + .find(|pm| pm.ecosystem() == Ecosystem::Python) +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -350,6 +388,53 @@ mod tests { ); } + #[test] + fn resolve_dispatch_pyproject_script_uses_uv_run() { + let mut ctx = context(); + ctx.package_managers.push(PackageManager::Uv); + ctx.tasks.push(Task { + name: "greenpy".to_string(), + source: TaskSource::PyprojectScripts, + run_target: None, + description: Some("greenpy.main:main".to_string()), + alias_of: None, + passthrough_to: None, + }); + let args = [String::from("--flag")]; + + let command = resolve_dispatch( + &ctx, + &ResolutionOverrides::default(), + "greenpy", + &args, + None, + ) + .expect("pyproject script should dispatch"); + + assert_eq!(command.get_program().to_string_lossy(), "uv"); + assert_eq!(command_args(&command), ["run", "greenpy", "--flag"]); + } + + #[test] + fn resolve_dispatch_pyproject_script_uses_poetry_run_when_detected() { + let mut ctx = context(); + ctx.package_managers.push(PackageManager::Poetry); + ctx.tasks.push(Task { + name: "greenpy".to_string(), + source: TaskSource::PyprojectScripts, + run_target: None, + description: None, + alias_of: None, + passthrough_to: None, + }); + + let command = resolve_dispatch(&ctx, &ResolutionOverrides::default(), "greenpy", &[], None) + .expect("pyproject script should dispatch"); + + assert_eq!(command.get_program().to_string_lossy(), "poetry"); + assert_eq!(command_args(&command), ["run", "greenpy"]); + } + #[test] fn build_pm_exec_command_go_versioned_uses_go_run() { let args = [String::from("--help")]; diff --git a/src/cmd/run/select.rs b/src/cmd/run/select.rs index 9fc4696..6cf46f4 100644 --- a/src/cmd/run/select.rs +++ b/src/cmd/run/select.rs @@ -125,6 +125,7 @@ fn source_dir(source: TaskSource, root: &Path) -> Option { TaskSource::Taskfile => tool::files::find_first_upwards(root, tool::go_task::FILENAMES), TaskSource::BaconToml => tool::files::find_first_upwards(root, tool::bacon::FILENAMES), TaskSource::MiseToml => tool::files::find_first_upwards(root, tool::mise::FILENAMES), + TaskSource::PyprojectScripts => tool::files::find_first_upwards(root, &["pyproject.toml"]), }; path.and_then(|path| path.parent().map(Path::to_path_buf)) } diff --git a/src/cmd/why.rs b/src/cmd/why.rs index 7a7e480..9c7ef8e 100644 --- a/src/cmd/why.rs +++ b/src/cmd/why.rs @@ -157,6 +157,7 @@ fn source_dir_for_task(task: &Task, ctx: &ProjectContext) -> Option { TaskSource::GoPackage => tool::go_pm::find_file(&ctx.root), TaskSource::BaconToml => tool::files::find_first(&ctx.root, tool::bacon::FILENAMES), TaskSource::MiseToml => tool::mise::find_file(&ctx.root), + TaskSource::PyprojectScripts => tool::files::find_first(&ctx.root, &["pyproject.toml"]), } } diff --git a/src/detect.rs b/src/detect.rs index 2667009..44b1668 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -8,7 +8,8 @@ use serde::Deserialize; use crate::tool; use crate::types::{ - DetectionWarning, NodeVersion, PackageManager, ProjectContext, Task, TaskRunner, TaskSource, + DetectionWarning, Ecosystem, NodeVersion, PackageManager, ProjectContext, Task, TaskRunner, + TaskSource, }; /// Scan `dir` for known config/lock files and return a populated [`ProjectContext`]. @@ -318,6 +319,13 @@ fn extract_tasks(dir: &Path, ctx: &mut ProjectContext) { let want_go_packages = ctx.package_managers.contains(&PackageManager::Go); let want_bacon = ctx.task_runners.contains(&TaskRunner::Bacon); let want_mise = ctx.task_runners.contains(&TaskRunner::Mise); + // `[project.scripts]` is shared PEP 621 metadata; surface it whenever + // any Python package manager (uv, poetry, pipenv) governs the project, + // since each dispatches the same entry points via its own `run`. + let want_pyproject_scripts = ctx + .package_managers + .iter() + .any(|pm| pm.ecosystem() == Ecosystem::Python); thread::scope(|s| { let pkg_json_h = want_pkg_json.then(|| { @@ -338,6 +346,8 @@ fn extract_tasks(dir: &Path, ctx: &mut ProjectContext) { let go_h = want_go_packages.then(|| s.spawn(move || tool::go_pm::extract_tasks(dir))); let bacon_h = want_bacon.then(|| s.spawn(move || tool::bacon::extract_tasks(dir))); let mise_h = want_mise.then(|| s.spawn(move || tool::mise::extract_tasks(dir))); + let pyproject_h = want_pyproject_scripts + .then(|| s.spawn(move || tool::python::extract_pyproject_scripts(dir))); if let Some(h) = pkg_json_h { push_package_json_tasks(ctx, h.join().expect("extractor thread panicked")); @@ -389,6 +399,13 @@ fn extract_tasks(dir: &Path, ctx: &mut ProjectContext) { if let Some(h) = mise_h { push_mise_tasks(ctx, h.join().expect("extractor thread panicked")); } + if let Some(h) = pyproject_h { + push_described_tasks( + ctx, + TaskSource::PyprojectScripts, + h.join().expect("extractor thread panicked"), + ); + } }); } @@ -728,6 +745,60 @@ mod tests { })); } + #[test] + fn detect_lists_pyproject_scripts_for_uv_projects() { + // Headline regression (issue): a uv project's `[project.scripts]` + // console entry points were detected as a package manager but + // never surfaced as runnable tasks. + let dir = TempDir::new("detect-pyproject-scripts-uv"); + fs::write(dir.path().join("uv.lock"), "").expect("uv.lock should be written"); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"greenpy\"\nversion = \"0.1.0\"\n\n[project.scripts]\nbodysuit = \"greenpy.bodysuit:main\"\ngreenpy = \"greenpy.main:main\"\nnavel-stamper = \"greenpy.navel_stamper:main\"\n", + ) + .expect("pyproject.toml should be written"); + + let ctx = detect(dir.path()); + + assert!( + ctx.package_managers + .contains(&crate::types::PackageManager::Uv) + ); + let names: Vec<&str> = ctx + .tasks + .iter() + .filter(|t| t.source == crate::types::TaskSource::PyprojectScripts) + .map(|t| t.name.as_str()) + .collect(); + assert_eq!(names, ["bodysuit", "greenpy", "navel-stamper"]); + // The entry-point target rides along as the task description. + assert!(ctx.tasks.iter().any(|t| { + t.source == crate::types::TaskSource::PyprojectScripts + && t.name == "greenpy" + && t.description.as_deref() == Some("greenpy.main:main") + })); + } + + #[test] + fn detect_lists_pyproject_scripts_for_poetry_projects() { + let dir = TempDir::new("detect-pyproject-scripts-poetry"); + fs::write( + dir.path().join("pyproject.toml"), + "[tool.poetry]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[project.scripts]\ncli = \"demo.cli:main\"\n", + ) + .expect("pyproject.toml should be written"); + + let ctx = detect(dir.path()); + + assert!( + ctx.package_managers + .contains(&crate::types::PackageManager::Poetry) + ); + assert!(ctx.tasks.iter().any(|t| { + t.source == crate::types::TaskSource::PyprojectScripts && t.name == "cli" + })); + } + #[test] fn detect_uses_deno_for_package_json_deno_projects() { let dir = TempDir::new("detect-package-json-deno"); diff --git a/src/schema/v1.rs b/src/schema/v1.rs index 10c7c3f..fc2e345 100644 --- a/src/schema/v1.rs +++ b/src/schema/v1.rs @@ -28,5 +28,6 @@ pub(crate) const fn source_label(source: TaskSource) -> &'static str { TaskSource::GoPackage => "go", TaskSource::BaconToml => "bacon.toml", TaskSource::MiseToml => "mise.toml", + TaskSource::PyprojectScripts => "pyproject.toml", } } diff --git a/src/tool/pipenv.rs b/src/tool/pipenv.rs index 9f3deb9..a5b8610 100644 --- a/src/tool/pipenv.rs +++ b/src/tool/pipenv.rs @@ -28,9 +28,17 @@ pub(crate) fn install_cmd(frozen: bool) -> Command { c } +/// `pipenv run