Skip to content

Commit

Permalink
Respect - as stdin channel for uv run (#6481)
Browse files Browse the repository at this point in the history
## Summary

Closes #6467.
  • Loading branch information
charliermarsh authored Aug 23, 2024
1 parent 01fc233 commit 57f833c
Show file tree
Hide file tree
Showing 7 changed files with 98 additions and 40 deletions.
3 changes: 2 additions & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,8 @@ pub enum ProjectCommand {
/// script and run with a Python interpreter, i.e., `uv run file.py` is
/// equivalent to `uv run python file.py`. If the script contains inline
/// dependency metadata, it will be installed into an isolated, ephemeral
/// environment.
/// environment. When used with `-`, the input will be read from stdin,
/// and treated as a Python script.
///
/// When used in a project, the project environment will be created and
/// updated before invoking the command.
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pub(crate) use project::add::add;
pub(crate) use project::init::init;
pub(crate) use project::lock::lock;
pub(crate) use project::remove::remove;
pub(crate) use project::run::{parse_script, run};
pub(crate) use project::run::{run, RunCommand};
pub(crate) use project::sync::sync;
pub(crate) use project::tree::tree;
pub(crate) use python::dir::dir as python_dir;
Expand Down
74 changes: 44 additions & 30 deletions crates/uv/src/commands/project/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::borrow::Cow;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt::Write;
use std::io::Read;
use std::path::{Path, PathBuf};

use anstream::eprint;
Expand All @@ -24,7 +25,7 @@ use uv_python::{
PythonPreference, PythonRequest, PythonVersionFile, VersionRequest,
};
use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_scripts::{Pep723Error, Pep723Script};
use uv_scripts::Pep723Script;
use uv_warnings::warn_user_once;
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError};

Expand All @@ -44,7 +45,7 @@ use crate::settings::ResolverInstallerSettings;
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn run(
script: Option<Pep723Script>,
command: ExternalCommand,
command: RunCommand,
requirements: Vec<RequirementsSource>,
show_resolution: bool,
locked: bool,
Expand Down Expand Up @@ -87,9 +88,6 @@ pub(crate) async fn run(
}
}

// Parse the input command.
let command = RunCommand::from(&command);

// Initialize any shared state.
let state = SharedState::default();

Expand Down Expand Up @@ -654,21 +652,6 @@ pub(crate) async fn run(
}
}

/// Read a [`Pep723Script`] from the given command.
pub(crate) async fn parse_script(
command: &ExternalCommand,
) -> Result<Option<Pep723Script>, Pep723Error> {
// Parse the input command.
let command = RunCommand::from(command);

let RunCommand::PythonScript(target, _) = &command else {
return Ok(None);
};

// Read the PEP 723 `script` metadata from the target script.
Pep723Script::read(&target).await
}

/// Returns `true` if we can skip creating an additional ephemeral environment in `uv run`.
fn can_skip_ephemeral(
spec: Option<&RequirementsSpecification>,
Expand Down Expand Up @@ -717,13 +700,15 @@ fn can_skip_ephemeral(
}

#[derive(Debug)]
enum RunCommand {
pub(crate) enum RunCommand {
/// Execute `python`.
Python(Vec<OsString>),
/// Execute a `python` script.
PythonScript(PathBuf, Vec<OsString>),
/// Execute a `pythonw` script (Windows only).
PythonGuiScript(PathBuf, Vec<OsString>),
/// Execute a `python` script provided via `stdin`.
PythonStdin(Vec<u8>),
/// Execute an external command.
External(OsString, Vec<OsString>),
/// Execute an empty command (in practice, `python` with no arguments).
Expand All @@ -737,6 +722,7 @@ impl RunCommand {
Self::Python(_) => Cow::Borrowed("python"),
Self::PythonScript(_, _) | Self::Empty => Cow::Borrowed("python"),
Self::PythonGuiScript(_, _) => Cow::Borrowed("pythonw"),
Self::PythonStdin(_) => Cow::Borrowed("python -c"),
Self::External(executable, _) => executable.to_string_lossy(),
}
}
Expand Down Expand Up @@ -774,6 +760,24 @@ impl RunCommand {
process.args(args);
process
}
Self::PythonStdin(script) => {
let mut process = Command::new(interpreter.sys_executable());
process.arg("-c");

#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
process.arg(OsString::from_vec(script.clone()));
}

#[cfg(not(unix))]
{
let script = String::from_utf8(script.clone()).expect("script is valid UTF-8");
process.arg(script);
}

process
}
Self::External(executable, args) => {
let mut process = Command::new(executable);
process.args(args);
Expand Down Expand Up @@ -808,6 +812,10 @@ impl std::fmt::Display for RunCommand {
}
Ok(())
}
Self::PythonStdin(_) => {
write!(f, "python -c")?;
Ok(())
}
Self::External(executable, args) => {
write!(f, "{}", executable.to_string_lossy())?;
for arg in args {
Expand All @@ -823,35 +831,41 @@ impl std::fmt::Display for RunCommand {
}
}

impl From<&ExternalCommand> for RunCommand {
fn from(command: &ExternalCommand) -> Self {
impl TryFrom<&ExternalCommand> for RunCommand {
type Error = std::io::Error;

fn try_from(command: &ExternalCommand) -> Result<Self, Self::Error> {
let (target, args) = command.split();

let Some(target) = target else {
return Self::Empty;
return Ok(Self::Empty);
};

let target_path = PathBuf::from(&target);
if target.eq_ignore_ascii_case("python") {
Self::Python(args.to_vec())
if target.eq_ignore_ascii_case("-") {
let mut buf = Vec::with_capacity(1024);
std::io::stdin().read_to_end(&mut buf)?;
Ok(Self::PythonStdin(buf))
} else if target.eq_ignore_ascii_case("python") {
Ok(Self::Python(args.to_vec()))
} else if target_path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("py"))
&& target_path.exists()
{
Self::PythonScript(target_path, args.to_vec())
Ok(Self::PythonScript(target_path, args.to_vec()))
} else if cfg!(windows)
&& target_path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pyw"))
&& target_path.exists()
{
Self::PythonGuiScript(target_path, args.to_vec())
Ok(Self::PythonGuiScript(target_path, args.to_vec()))
} else {
Self::External(
Ok(Self::External(
target.clone(),
args.iter().map(std::clone::Clone::clone).collect(),
)
))
}
}
}
28 changes: 24 additions & 4 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use uv_settings::{Combine, FilesystemOptions, Options};
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::{DiscoveryOptions, Workspace};

use crate::commands::{parse_script, ExitStatus, ToolRunCommand};
use crate::commands::{ExitStatus, RunCommand, ToolRunCommand};
use crate::printer::Printer;
use crate::settings::{
CacheSettings, GlobalSettings, PipCheckSettings, PipCompileSettings, PipFreezeSettings,
Expand Down Expand Up @@ -130,10 +130,25 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
project.combine(user)
};

// 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(RunCommand::try_from(command)?)
} else {
None
}
} else {
None
};

// If the target is a PEP 723 script, parse it.
let script = if let Commands::Project(command) = &*cli.command {
if let ProjectCommand::Run(uv_cli::RunArgs { command, .. }) = &**command {
parse_script(command).await?
if let ProjectCommand::Run(uv_cli::RunArgs { .. }) = &**command {
if let Some(RunCommand::PythonScript(script, _)) = run_command.as_ref() {
Pep723Script::read(&script).await?
} else {
None
}
} else if let ProjectCommand::Remove(uv_cli::RemoveArgs {
script: Some(script),
..
Expand Down Expand Up @@ -691,6 +706,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
Commands::Project(project) => {
Box::pin(run_project(
project,
run_command,
script,
globals,
cli.no_config,
Expand Down Expand Up @@ -963,6 +979,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
/// Run a [`ProjectCommand`].
async fn run_project(
project_command: Box<ProjectCommand>,
command: Option<RunCommand>,
script: Option<Pep723Script>,
globals: GlobalSettings,
// TODO(zanieb): Determine a better story for passing `no_config` in here
Expand Down Expand Up @@ -1039,9 +1056,12 @@ 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(
script,
args.command,
command,
requirements,
args.show_resolution || globals.verbose > 0,
args.locked,
Expand Down
4 changes: 1 addition & 3 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,6 @@ pub(crate) struct RunSettings {
pub(crate) frozen: bool,
pub(crate) extras: ExtrasSpecification,
pub(crate) dev: bool,
pub(crate) command: ExternalCommand,
pub(crate) with: Vec<String>,
pub(crate) with_editable: Vec<String>,
pub(crate) with_requirements: Vec<PathBuf>,
Expand All @@ -215,7 +214,7 @@ impl RunSettings {
no_all_extras,
dev,
no_dev,
command,
command: _,
with,
with_editable,
with_requirements,
Expand All @@ -239,7 +238,6 @@ impl RunSettings {
extra.unwrap_or_default(),
),
dev: flag(dev, no_dev).unwrap_or(true),
command,
with,
with_editable,
with_requirements: with_requirements
Expand Down
25 changes: 25 additions & 0 deletions crates/uv/tests/run.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![cfg(all(feature = "python", feature = "pypi"))]
#![allow(clippy::disallowed_types)]

use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
Expand Down Expand Up @@ -1408,3 +1409,27 @@ fn run_no_project() -> Result<()> {

Ok(())
}

#[test]
fn run_stdin() -> Result<()> {
let context = TestContext::new("3.12");

let test_script = context.temp_dir.child("main.py");
test_script.write_str(indoc! { r#"
print("Hello, world!")
"#
})?;

let mut command = context.run();
let command_with_args = command.stdin(std::fs::File::open(test_script)?).arg("-");
uv_snapshot!(context.filters(), command_with_args, @r###"
success: true
exit_code: 0
----- stdout -----
Hello, world!
----- stderr -----
"###);

Ok(())
}
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Run a command or script.

Ensures that the command runs in a Python environment.

When used with a file ending in `.py`, the file will be treated as a script and run with a Python interpreter, i.e., `uv run file.py` is equivalent to `uv run python file.py`. If the script contains inline dependency metadata, it will be installed into an isolated, ephemeral environment.
When used with a file ending in `.py`, the file will be treated as a script and run with a Python interpreter, i.e., `uv run file.py` is equivalent to `uv run python file.py`. If the script contains inline dependency metadata, it will be installed into an isolated, ephemeral environment. When used with `-`, the input will be read from stdin, and treated as a Python script.

When used in a project, the project environment will be created and updated before invoking the command.

Expand Down

0 comments on commit 57f833c

Please sign in to comment.