uv run: List available scripts when a script is not specified (#7687)

Signed-off-by: Kemal Akkoyun <kakkoyun@gmail.com>
## Summary

This PR adds the ability to list available scripts in the environment
when `uv run` is invoked without any arguments.
It somewhat mimics the behavior of `rye run` command
(See https://rye.astral.sh/guide/commands/run).

This is an attempt to fix #4024.

## Test Plan

I added test cases. The CI pipeline should pass.

### Manuel Tests

```shell
❯ uv run
Provide a command or script to invoke with `uv run <command>` or `uv run script.py`.

The following scripts are available:

normalizer
python
python3
python3.12

See `uv run --help` for more information.
```

---------

Signed-off-by: Kemal Akkoyun <kakkoyun@gmail.com>
Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Kemal Akkoyun 2024-10-08 21:34:50 +02:00 committed by GitHub
parent 282fab5f70
commit 1a39ffe391
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 152 additions and 16 deletions

4
Cargo.lock generated
View file

@ -4679,12 +4679,14 @@ dependencies = [
"fs2", "fs2",
"junction", "junction",
"path-slash", "path-slash",
"rustix",
"schemars", "schemars",
"serde", "serde",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing", "tracing",
"urlencoding", "urlencoding",
"winsafe 0.0.22",
] ]
[[package]] [[package]]
@ -4982,7 +4984,6 @@ dependencies = [
"reqwest", "reqwest",
"reqwest-middleware", "reqwest-middleware",
"rmp-serde", "rmp-serde",
"rustix",
"same-file", "same-file",
"schemars", "schemars",
"serde", "serde",
@ -5014,7 +5015,6 @@ dependencies = [
"windows-registry", "windows-registry",
"windows-result 0.2.0", "windows-result 0.2.0",
"windows-sys 0.59.0", "windows-sys 0.59.0",
"winsafe 0.0.22",
] ]
[[package]] [[package]]

View file

@ -2543,7 +2543,7 @@ pub struct RunArgs {
/// If the path to a Python script (i.e., ending in `.py`), it will be /// If the path to a Python script (i.e., ending in `.py`), it will be
/// executed with the Python interpreter. /// executed with the Python interpreter.
#[command(subcommand)] #[command(subcommand)]
pub command: ExternalCommand, pub command: Option<ExternalCommand>,
/// Run with the given packages installed. /// Run with the given packages installed.
/// ///

View file

@ -29,6 +29,12 @@ tempfile = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
urlencoding = { workspace = true } urlencoding = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
winsafe = { workspace = true }
[target.'cfg(any(unix, target_os = "wasi", target_os = "redox"))'.dependencies]
rustix = { workspace = true }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
junction = { workspace = true } junction = { workspace = true }

View file

@ -8,6 +8,7 @@ pub use crate::path::*;
pub mod cachedir; pub mod cachedir;
mod path; mod path;
pub mod which;
/// Reads data from the path and requires that it be valid UTF-8 or UTF-16. /// Reads data from the path and requires that it be valid UTF-8 or UTF-16.
/// ///

View file

@ -3,7 +3,7 @@ use std::path::Path;
/// Check whether a path in PATH is a valid executable. /// Check whether a path in PATH is a valid executable.
/// ///
/// Derived from `which`'s `Checker`. /// Derived from `which`'s `Checker`.
pub(crate) fn is_executable(path: &Path) -> bool { pub fn is_executable(path: &Path) -> bool {
#[cfg(any(unix, target_os = "wasi", target_os = "redox"))] #[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
{ {
if rustix::fs::access(path, rustix::fs::Access::EXEC_OK).is_err() { if rustix::fs::access(path, rustix::fs::Access::EXEC_OK).is_err() {

View file

@ -53,12 +53,8 @@ tracing = { workspace = true }
url = { workspace = true } url = { workspace = true }
which = { workspace = true } which = { workspace = true }
[target.'cfg(any(unix, target_os = "wasi", target_os = "redox"))'.dependencies]
rustix = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { workspace = true } windows-sys = { workspace = true }
winsafe = { workspace = true }
windows-registry = { workspace = true } windows-registry = { workspace = true }
windows-result = { workspace = true } windows-result = { workspace = true }

View file

@ -10,6 +10,7 @@ use tracing::{debug, instrument, trace};
use which::{which, which_all}; use which::{which, which_all};
use uv_cache::Cache; use uv_cache::Cache;
use uv_fs::which::is_executable;
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_pep440::{Prerelease, Version, VersionSpecifier, VersionSpecifiers}; use uv_pep440::{Prerelease, Version, VersionSpecifier, VersionSpecifiers};
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
@ -27,7 +28,6 @@ use crate::virtualenv::{
conda_prefix_from_env, virtualenv_from_env, virtualenv_from_working_dir, conda_prefix_from_env, virtualenv_from_env, virtualenv_from_working_dir,
virtualenv_python_executable, virtualenv_python_executable,
}; };
use crate::which::is_executable;
use crate::{Interpreter, PythonVersion}; use crate::{Interpreter, PythonVersion};
/// A request to find a Python installation. /// A request to find a Python installation.

View file

@ -37,7 +37,6 @@ mod python_version;
mod target; mod target;
mod version_files; mod version_files;
mod virtualenv; mod virtualenv;
mod which;
#[cfg(not(test))] #[cfg(not(test))]
pub(crate) fn current_dir() -> Result<std::path::PathBuf, std::io::Error> { pub(crate) fn current_dir() -> Result<std::path::PathBuf, std::io::Error> {

View file

@ -18,9 +18,11 @@ use uv_configuration::{
Concurrency, DevMode, EditableMode, ExtrasSpecification, InstallOptions, SourceStrategy, Concurrency, DevMode, EditableMode, ExtrasSpecification, InstallOptions, SourceStrategy,
}; };
use uv_distribution::LoweredRequirement; use uv_distribution::LoweredRequirement;
use uv_fs::which::is_executable;
use uv_fs::{PythonExt, Simplified}; use uv_fs::{PythonExt, Simplified};
use uv_installer::{SatisfiesResult, SitePackages}; use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_python::{ use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest, PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
@ -51,7 +53,7 @@ use crate::settings::ResolverInstallerSettings;
pub(crate) async fn run( pub(crate) async fn run(
project_dir: &Path, project_dir: &Path,
script: Option<Pep723Script>, script: Option<Pep723Script>,
command: RunCommand, command: Option<RunCommand>,
requirements: Vec<RequirementsSource>, requirements: Vec<RequirementsSource>,
show_resolution: bool, show_resolution: bool,
locked: bool, locked: bool,
@ -751,6 +753,73 @@ pub(crate) async fn run(
.as_ref() .as_ref()
.map_or_else(|| &base_interpreter, |env| env.interpreter()); .map_or_else(|| &base_interpreter, |env| env.interpreter());
// Check if any run command is given.
// If not, print the available scripts for the current interpreter.
let Some(command) = command else {
writeln!(
printer.stdout(),
"Provide a command or script to invoke with `uv run <command>` or `uv run <script>.py`.\n"
)?;
#[allow(clippy::map_identity)]
let commands = interpreter
.scripts()
.read_dir()
.ok()
.into_iter()
.flatten()
.map(|entry| match entry {
Ok(entry) => Ok(entry),
Err(err) => {
// If we can't read the entry, fail.
// This could be a symptom of a more serious problem.
warn!("Failed to read entry: {}", err);
Err(err)
}
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter(|entry| {
entry
.file_type()
.is_ok_and(|file_type| file_type.is_file() || file_type.is_symlink())
})
.map(|entry| entry.path())
.filter(|path| is_executable(path))
.map(|path| {
if cfg!(windows) {
// Remove the extensions.
path.with_extension("")
} else {
path
}
})
.map(|path| {
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
.filter(|command| {
!command.starts_with("activate") && !command.starts_with("deactivate")
})
.sorted()
.collect_vec();
if !commands.is_empty() {
writeln!(
printer.stdout(),
"The following commands are available in the environment:\n"
)?;
for command in commands {
writeln!(printer.stdout(), "- {command}")?;
}
}
let help = format!("See `{}` for more information.", "uv run --help".bold());
writeln!(printer.stdout(), "\n{help}")?;
return Ok(ExitStatus::Error);
};
debug!("Running `{command}`"); debug!("Running `{command}`");
let mut process = command.as_command(interpreter); let mut process = command.as_command(interpreter);

View file

@ -133,7 +133,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
// Parse the external command, if necessary. // Parse the external command, if necessary.
let run_command = if let Commands::Project(command) = &*cli.command { let run_command = if let Commands::Project(command) = &*cli.command {
if let ProjectCommand::Run(uv_cli::RunArgs { if let ProjectCommand::Run(uv_cli::RunArgs {
command, command: Some(command),
module, module,
script, script,
.. ..
@ -1239,9 +1239,6 @@ async fn run_project(
) )
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Given `ProjectCommand::Run`, we always expect a `RunCommand` to be present.
let command = command.expect("run command is required");
Box::pin(commands::run( Box::pin(commands::run(
project_dir, project_dir,
script, script,

View file

@ -198,6 +198,74 @@ fn run_args() -> Result<()> {
Ok(()) Ok(())
} }
/// Run without specifying any argunments.
/// This should list the available scripts.
#[test]
fn run_no_args() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.8"
dependencies = []
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
"#
})?;
// Run without specifying any argunments.
#[cfg(not(windows))]
uv_snapshot!(context.filters(), context.run(), @r###"
success: false
exit_code: 2
----- stdout -----
Provide a command or script to invoke with `uv run <command>` or `uv run <script>.py`.
The following commands are available in the environment:
- python
- python3
- python3.12
See `uv run --help` for more information.
----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ foo==1.0.0 (from file://[TEMP_DIR]/)
"###);
#[cfg(windows)]
uv_snapshot!(context.filters(), context.run(), @r###"
success: false
exit_code: 2
----- stdout -----
Provide a command or script to invoke with `uv run <command>` or `uv run <script>.py`.
The following commands are available in the environment:
- pydoc
- python
- pythonw
See `uv run --help` for more information.
----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ foo==1.0.0 (from file://[TEMP_DIR]/)
"###);
Ok(())
}
/// Run a PEP 723-compatible script. The script should take precedence over the workspace /// Run a PEP 723-compatible script. The script should take precedence over the workspace
/// dependencies. /// dependencies.
#[test] #[test]

View file

@ -65,7 +65,7 @@ Arguments following the command (or script) are not interpreted as arguments to
<h3 class="cli-reference">Usage</h3> <h3 class="cli-reference">Usage</h3>
``` ```
uv run [OPTIONS] <COMMAND> uv run [OPTIONS] [COMMAND]
``` ```
<h3 class="cli-reference">Options</h3> <h3 class="cli-reference">Options</h3>