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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`,
`poetry run <name>`, or `pipenv run <name>`. 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`
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
58 changes: 51 additions & 7 deletions src/cmd/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ pub(crate) fn list(
let parsed_source = match source {
None => None,
Some(label) => Some(TaskSource::from_label(label).ok_or_else(|| {
let expected = expected_source_labels();
anyhow!(
"--source {label:?}: unknown source label (expected one of: package.json, \
make, just, task, turbo, deno, cargo, go, bacon, mise — legacy filename \
forms like justfile/bacon.toml/Makefile are also accepted)",
"--source {label:?}: unknown source label (expected one of: {expected} — legacy \
filename forms like justfile/bacon.toml/Makefile are also accepted)",
)
})?),
};
Expand Down Expand Up @@ -80,6 +80,15 @@ pub(crate) fn list(
Ok(())
}

fn expected_source_labels() -> String {
TaskSource::all()
.iter()
.copied()
.map(TaskSource::label)
.collect::<Vec<_>>()
.join(", ")
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RenderMode {
Rich,
Expand Down Expand Up @@ -190,6 +199,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);
Expand Down Expand Up @@ -370,6 +380,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);
Expand Down Expand Up @@ -472,6 +483,9 @@ fn source_path(source: TaskSource, root: &Path) -> Option<PathBuf> {
tool::files::find_first(root, tool::bacon::FILENAMES).filter(|path| path.is_file())
}
TaskSource::MiseToml => tool::mise::find_file(root),
TaskSource::PyprojectScripts => {
tool::python::find_pyproject_upwards(root).filter(|path| path.is_file())
}
Comment thread
kjanat marked this conversation as resolved.
}?;

Some(path.canonicalize().unwrap_or(path))
Expand Down Expand Up @@ -524,14 +538,16 @@ fn osc8_link(label: &str, url: &str) -> String {
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};

use super::{
RenderMode, file_uri, render_rich_row, render_tasks_grouped, render_tasks_grouped_rich,
select_render_mode_for, source_label, source_path,
RenderMode, expected_source_labels, file_uri, render_rich_row, render_tasks_grouped,
render_tasks_grouped_rich, select_render_mode_for, source_label, source_path,
};
use crate::resolver::ResolutionOverrides;
use crate::schema::CURRENT_VERSION;
use crate::tool::test_support::TempDir;
use crate::types::{Task, TaskSource};
use crate::types::{ProjectContext, Task, TaskSource};

#[test]
fn source_path_finds_existing_config_variant() {
Expand Down Expand Up @@ -570,6 +586,34 @@ mod tests {
assert!(path.ends_with("Taskfile.dist.yml"));
}

#[test]
fn invalid_source_error_mentions_pyproject() {
let ctx = ProjectContext {
root: PathBuf::from("."),
package_managers: Vec::new(),
task_runners: Vec::new(),
tasks: Vec::new(),
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
};

let err = super::list(
&ctx,
&ResolutionOverrides::default(),
false,
false,
Some("wat"),
CURRENT_VERSION,
)
.expect_err("invalid source should error");

let message = format!("{err:#}");
assert!(message.contains("pyproject.toml"));
assert!(expected_source_labels().contains("pyproject.toml"));
}

#[test]
fn source_label_uses_osc8_when_terminal_and_file_exists() {
let dir = TempDir::new("list-source-label-link");
Expand Down
2 changes: 2 additions & 0 deletions src/cmd/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ mod select;
pub(crate) use qualify::{allowed_runner_sources, precheck_task, runner_constraint_error};
pub(crate) use select::{select_task_entry, source_depth, source_priority};

pub(crate) use dispatch::{ResolvedPythonPm, resolve_python_pm};

use crate::resolver::ResolutionOverrides;
use crate::types::ProjectContext;

Expand Down
140 changes: 138 additions & 2 deletions src/cmd/run/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ use super::qualify::{
runner_constraint_error,
};
use super::select::select_task_entry;
use crate::resolver::{ResolutionOverrides, ResolveError, Resolver};
use crate::resolver::{OverrideOrigin, 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.
///
Expand Down Expand Up @@ -214,6 +214,38 @@ fn build_pm_exec_command(
}
}

/// Python package manager decision for `[project.scripts]` dispatch.
#[derive(Debug, Clone)]
pub(crate) struct ResolvedPythonPm {
pub(crate) pm: PackageManager,
via: PythonPmResolution,
}

#[derive(Debug, Clone)]
enum PythonPmResolution {
Override(OverrideOrigin),
DetectedProject,
}

impl ResolvedPythonPm {
pub(crate) fn describe(&self) -> String {
match &self.via {
PythonPmResolution::Override(OverrideOrigin::CliFlag) => {
format!("{} via --pm (CLI override)", self.pm.label())
}
PythonPmResolution::Override(OverrideOrigin::EnvVar) => {
format!("{} via RUNNER_PM (environment)", self.pm.label())
}
PythonPmResolution::Override(OverrideOrigin::ConfigFile { path }) => {
format!("{} via runner.toml at {}", self.pm.label(), path.display())
}
PythonPmResolution::DetectedProject => {
format!("{} via detected Python project", self.pm.label())
}
}
}
}

/// Bun special-case for `runner test` when the project has no
/// `package.json` `test` script: forward to `bun test`.
///
Expand Down Expand Up @@ -282,9 +314,66 @@ 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(decision) = resolve_python_pm(ctx, overrides) else {
bail!(
"no Python package manager detected to run {:?}; install uv, poetry, or pipenv",
entry.name,
);
};
if overrides.explain {
eprintln!(
"{} {} resolved: {}",
"·".dimmed(),
"runner".dimmed(),
decision.describe(),
);
}
let pm = decision.pm;
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.
pub(crate) fn resolve_python_pm(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
) -> Option<ResolvedPythonPm> {
if let Some(o) = overrides.pm.as_ref()
&& o.pm.ecosystem() == Ecosystem::Python
{
return Some(ResolvedPythonPm {
pm: o.pm,
via: PythonPmResolution::Override(o.origin.clone()),
});
}
if let Some(o) = overrides.pm_by_ecosystem.get(&Ecosystem::Python) {
return Some(ResolvedPythonPm {
pm: o.pm,
via: PythonPmResolution::Override(o.origin.clone()),
});
}
ctx.package_managers
.iter()
.copied()
.find(|pm| pm.ecosystem() == Ecosystem::Python)
.map(|pm| ResolvedPythonPm {
pm,
via: PythonPmResolution::DetectedProject,
})
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;
Expand Down Expand Up @@ -350,6 +439,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")];
Expand Down
1 change: 1 addition & 0 deletions src/cmd/run/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ fn source_dir(source: TaskSource, root: &Path) -> Option<PathBuf> {
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))
}
Loading