Skip to content

Commit

Permalink
uv run: List available scripts when a script is not specified (#7687)
Browse files Browse the repository at this point in the history
Signed-off-by: Kemal Akkoyun <[email protected]>
## 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 <[email protected]>
Co-authored-by: Zanie Blue <[email protected]>
  • Loading branch information
kakkoyun and zanieb authored Oct 8, 2024
1 parent 282fab5 commit 1a39ffe
Show file tree
Hide file tree
Showing 12 changed files with 152 additions and 16 deletions.
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2543,7 +2543,7 @@ pub struct RunArgs {
/// If the path to a Python script (i.e., ending in `.py`), it will be
/// executed with the Python interpreter.
#[command(subcommand)]
pub command: ExternalCommand,
pub command: Option<ExternalCommand>,

/// Run with the given packages installed.
///
Expand Down
6 changes: 6 additions & 0 deletions crates/uv-fs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ tempfile = { workspace = true }
tracing = { 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]
junction = { workspace = true }

Expand Down
1 change: 1 addition & 0 deletions crates/uv-fs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub use crate::path::*;

pub mod cachedir;
mod path;
pub mod which;

/// Reads data from the path and requires that it be valid UTF-8 or UTF-16.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::path::Path;
/// Check whether a path in PATH is a valid executable.
///
/// 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"))]
{
if rustix::fs::access(path, rustix::fs::Access::EXEC_OK).is_err() {
Expand Down
4 changes: 0 additions & 4 deletions crates/uv-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,8 @@ tracing = { workspace = true }
url = { 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]
windows-sys = { workspace = true }
winsafe = { workspace = true }
windows-registry = { workspace = true }
windows-result = { workspace = true }

Expand Down
2 changes: 1 addition & 1 deletion crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use tracing::{debug, instrument, trace};
use which::{which, which_all};

use uv_cache::Cache;
use uv_fs::which::is_executable;
use uv_fs::Simplified;
use uv_pep440::{Prerelease, Version, VersionSpecifier, VersionSpecifiers};
use uv_warnings::warn_user_once;
Expand All @@ -27,7 +28,6 @@ use crate::virtualenv::{
conda_prefix_from_env, virtualenv_from_env, virtualenv_from_working_dir,
virtualenv_python_executable,
};
use crate::which::is_executable;
use crate::{Interpreter, PythonVersion};

/// A request to find a Python installation.
Expand Down
1 change: 0 additions & 1 deletion crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ mod python_version;
mod target;
mod version_files;
mod virtualenv;
mod which;

#[cfg(not(test))]
pub(crate) fn current_dir() -> Result<std::path::PathBuf, std::io::Error> {
Expand Down
71 changes: 70 additions & 1 deletion crates/uv/src/commands/project/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ use uv_configuration::{
Concurrency, DevMode, EditableMode, ExtrasSpecification, InstallOptions, SourceStrategy,
};
use uv_distribution::LoweredRequirement;
use uv_fs::which::is_executable;
use uv_fs::{PythonExt, Simplified};
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName;

use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
Expand Down Expand Up @@ -51,7 +53,7 @@ use crate::settings::ResolverInstallerSettings;
pub(crate) async fn run(
project_dir: &Path,
script: Option<Pep723Script>,
command: RunCommand,
command: Option<RunCommand>,
requirements: Vec<RequirementsSource>,
show_resolution: bool,
locked: bool,
Expand Down Expand Up @@ -751,6 +753,73 @@ pub(crate) async fn run(
.as_ref()
.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}`");
let mut process = command.as_command(interpreter);

Expand Down
5 changes: 1 addition & 4 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
// Parse the external command, if necessary.
let run_command = if let Commands::Project(command) = &*cli.command {
if let ProjectCommand::Run(uv_cli::RunArgs {
command,
command: Some(command),
module,
script,
..
Expand Down Expand Up @@ -1239,9 +1239,6 @@ async fn run_project(
)
.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(
project_dir,
script,
Expand Down
68 changes: 68 additions & 0 deletions crates/uv/tests/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,74 @@ fn run_args() -> Result<()> {
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
/// dependencies.
#[test]
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Arguments following the command (or script) are not interpreted as arguments to
<h3 class="cli-reference">Usage</h3>

```
uv run [OPTIONS] <COMMAND>
uv run [OPTIONS] [COMMAND]
```

<h3 class="cli-reference">Options</h3>
Expand Down

0 comments on commit 1a39ffe

Please sign in to comment.