diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index a463da455..79f9dac57 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -22,7 +22,9 @@ pub enum Pep723Item { /// A PEP 723 script read from disk. Script(Pep723Script), /// A PEP 723 script provided via `stdin`. - Stdin(Pep723Stdin), + Stdin(Pep723Metadata), + /// A PEP 723 script provided via a remote URL. + Remote(Pep723Metadata), } impl Pep723Item { @@ -30,7 +32,8 @@ impl Pep723Item { pub fn metadata(&self) -> &Pep723Metadata { match self { Self::Script(script) => &script.metadata, - Self::Stdin(stdin) => &stdin.metadata, + Self::Stdin(metadata) => metadata, + Self::Remote(metadata) => metadata, } } @@ -38,7 +41,8 @@ impl Pep723Item { pub fn into_metadata(self) -> Pep723Metadata { match self { Self::Script(script) => script.metadata, - Self::Stdin(stdin) => stdin.metadata, + Self::Stdin(metadata) => metadata, + Self::Remote(metadata) => metadata, } } } @@ -182,29 +186,6 @@ impl Pep723Script { } } -/// A PEP 723 script, provided via `stdin`. -#[derive(Debug)] -pub struct Pep723Stdin { - metadata: Pep723Metadata, -} - -impl Pep723Stdin { - /// Parse the PEP 723 `script` metadata from `stdin`. - pub fn parse(contents: &[u8]) -> Result, Pep723Error> { - // Extract the `script` tag. - let ScriptTag { metadata, .. } = match ScriptTag::parse(contents) { - Ok(Some(tag)) => tag, - Ok(None) => return Ok(None), - Err(err) => return Err(err), - }; - - // Parse the metadata. - let metadata = Pep723Metadata::from_str(&metadata)?; - - Ok(Some(Self { metadata })) - } -} - /// PEP 723 metadata as parsed from a `script` comment block. /// /// See: @@ -219,6 +200,42 @@ pub struct Pep723Metadata { pub raw: String, } +impl Pep723Metadata { + /// Parse the PEP 723 metadata from `stdin`. + pub fn parse(contents: &[u8]) -> Result, Pep723Error> { + // Extract the `script` tag. + let ScriptTag { metadata, .. } = match ScriptTag::parse(contents) { + Ok(Some(tag)) => tag, + Ok(None) => return Ok(None), + Err(err) => return Err(err), + }; + + // Parse the metadata. + Ok(Some(Self::from_str(&metadata)?)) + } + + /// Read the PEP 723 `script` metadata from a Python file, if it exists. + /// + /// See: + pub async fn read(file: impl AsRef) -> Result, Pep723Error> { + let contents = match fs_err::tokio::read(&file).await { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + + // Extract the `script` tag. + let ScriptTag { metadata, .. } = match ScriptTag::parse(&contents) { + Ok(Some(tag)) => tag, + Ok(None) => return Ok(None), + Err(err) => return Err(err), + }; + + // Parse the metadata. + Ok(Some(Self::from_str(&metadata)?)) + } +} + impl FromStr for Pep723Metadata { type Err = Pep723Error; diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index bf36eb8ab..1d4e6fbc9 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -7,10 +7,12 @@ use std::path::{Path, PathBuf}; use anstream::eprint; use anyhow::{anyhow, bail, Context}; +use futures::StreamExt; use itertools::Itertools; use owo_colors::OwoColorize; use tokio::process::Command; use tracing::{debug, warn}; +use url::Url; use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::{BaseClientBuilder, Connectivity}; @@ -107,18 +109,28 @@ pub(crate) async fn run( // Determine whether the command to execute is a PEP 723 script. let temp_dir; let script_interpreter = if let Some(script) = script { - if let Pep723Item::Script(script) = &script { - writeln!( - printer.stderr(), - "Reading inline script metadata from: {}", - script.path.user_display().cyan() - )?; - } else { - writeln!( - printer.stderr(), - "Reading inline script metadata from: `{}`", - "stdin".cyan() - )?; + match &script { + Pep723Item::Script(script) => { + writeln!( + printer.stderr(), + "Reading inline script metadata from `{}`", + script.path.user_display().cyan() + )?; + } + Pep723Item::Stdin(_) => { + writeln!( + printer.stderr(), + "Reading inline script metadata from `{}`", + "stdin".cyan() + )?; + } + Pep723Item::Remote(_) => { + writeln!( + printer.stderr(), + "Reading inline script metadata from {}", + "remote URL".cyan() + )?; + } } let (source, python_request) = if let Some(request) = python.as_deref() { @@ -196,7 +208,7 @@ pub(crate) async fn run( .parent() .expect("script path has no parent") .to_owned(), - Pep723Item::Stdin(..) => std::env::current_dir()?, + Pep723Item::Stdin(..) | Pep723Item::Remote(..) => std::env::current_dir()?, }; let script = script.into_metadata(); @@ -962,6 +974,8 @@ pub(crate) enum RunCommand { PythonZipapp(PathBuf, Vec), /// Execute a `python` script provided via `stdin`. PythonStdin(Vec), + /// Execute a Python script provided via a remote URL. + PythonRemote(tempfile::NamedTempFile, Vec), /// Execute an external command. External(OsString, Vec), /// Execute an empty command (in practice, `python` with no arguments). @@ -972,10 +986,11 @@ impl RunCommand { /// Return the name of the target executable, for display purposes. fn display_executable(&self) -> Cow<'_, str> { match self { - Self::Python(_) => Cow::Borrowed("python"), - Self::PythonScript(..) + Self::Python(_) + | Self::PythonScript(..) | Self::PythonPackage(..) | Self::PythonZipapp(..) + | Self::PythonRemote(..) | Self::Empty => Cow::Borrowed("python"), Self::PythonModule(..) => Cow::Borrowed("python -m"), Self::PythonGuiScript(..) => Cow::Borrowed("pythonw"), @@ -1000,6 +1015,12 @@ impl RunCommand { process.args(args); process } + Self::PythonRemote(target, args) => { + let mut process = Command::new(interpreter.sys_executable()); + process.arg(target.path()); + process.args(args); + process + } Self::PythonModule(module, args) => { let mut process = Command::new(interpreter.sys_executable()); process.arg("-m"); @@ -1088,7 +1109,7 @@ impl std::fmt::Display for RunCommand { } Ok(()) } - Self::PythonStdin(_) => { + Self::PythonStdin(..) | Self::PythonRemote(..) => { write!(f, "python -c")?; Ok(()) } @@ -1108,23 +1129,64 @@ impl std::fmt::Display for RunCommand { } impl RunCommand { - pub(crate) fn from_args( + /// Determine the [`RunCommand`] for a given set of arguments. + pub(crate) async fn from_args( command: &ExternalCommand, module: bool, script: bool, + connectivity: Connectivity, + native_tls: bool, ) -> anyhow::Result { let (target, args) = command.split(); let Some(target) = target else { return Ok(Self::Empty); }; + let target_path = PathBuf::from(target); + + // Determine whether the user provided a remote script. + if target_path.starts_with("http://") || target_path.starts_with("https://") { + // Only continue if we are absolutely certain no local file exists. + // + // We don't do this check on Windows since the file path would + // be invalid anyway, and thus couldn't refer to a local file. + if !cfg!(unix) || matches!(target_path.try_exists(), Ok(false)) { + let url = Url::parse(&target.to_string_lossy())?; + + let file_stem = url + .path_segments() + .and_then(Iterator::last) + .and_then(|segment| segment.strip_suffix(".py")) + .unwrap_or("script"); + let file = tempfile::Builder::new() + .prefix(file_stem) + .suffix(".py") + .tempfile()?; + + let client = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls) + .build(); + let response = client.client().get(url.clone()).send().await?; + + // Stream the response to the file. + let mut writer = file.as_file(); + let mut reader = response.bytes_stream(); + while let Some(chunk) = reader.next().await { + use std::io::Write; + writer.write_all(&chunk?)?; + } + + return Ok(Self::PythonRemote(file, args.to_vec())); + } + } + if module { return Ok(Self::PythonModule(target.clone(), args.to_vec())); } else if script { return Ok(Self::PythonScript(target.clone().into(), args.to_vec())); } - let target_path = PathBuf::from(target); let metadata = target_path.metadata(); let is_file = metadata.as_ref().map_or(false, std::fs::Metadata::is_file); let is_dir = metadata.as_ref().map_or(false, std::fs::Metadata::is_dir); diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 200fecfdb..9ea879d69 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -19,16 +19,12 @@ use uv_cli::{ compat::CompatArgs, BuildBackendCommand, CacheCommand, CacheNamespace, Cli, Commands, PipCommand, PipNamespace, ProjectCommand, }; -use uv_cli::{ - ExternalCommand, GlobalArgs, PythonCommand, PythonNamespace, ToolCommand, ToolNamespace, - TopLevelArgs, -}; +use uv_cli::{PythonCommand, PythonNamespace, ToolCommand, ToolNamespace, TopLevelArgs}; #[cfg(feature = "self-update")] use uv_cli::{SelfCommand, SelfNamespace, SelfUpdateArgs}; -use uv_client::BaseClientBuilder; use uv_fs::CWD; use uv_requirements::RequirementsSource; -use uv_scripts::{Pep723Item, Pep723Script, Pep723Stdin}; +use uv_scripts::{Pep723Item, Pep723Metadata, Pep723Script}; use uv_settings::{Combine, FilesystemOptions, Options}; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::{DiscoveryOptions, Workspace}; @@ -47,62 +43,6 @@ pub(crate) mod printer; pub(crate) mod settings; pub(crate) mod version; -/// Resolves the script target for a run command. -/// -/// If it's a local file, does nothing. If it's a URL, downloads the content -/// to a temporary file and updates the command. Prioritizes local files over URLs. -/// Returns Some(NamedTempFile) if a remote script was downloaded, None otherwise. -async fn resolve_script_target( - command: &mut ExternalCommand, - global_args: &GlobalArgs, - filesystem: Option<&FilesystemOptions>, -) -> Result> { - use std::io::Write; - - let Some(target) = command.get_mut(0) else { - return Ok(None); - }; - - // Only continue if we are absolutely certain no local file exists. - // - // We don't do this check on Windows since the file path would - // be invalid anyway, and thus couldn't refer to a local file. - if cfg!(unix) && !matches!(Path::new(target).try_exists(), Ok(false)) { - return Ok(None); - } - - let maybe_url = target.to_string_lossy(); - - // Only continue if the target truly looks like a URL. - if !(maybe_url.starts_with("http://") || maybe_url.starts_with("https://")) { - return Ok(None); - } - - let script_url = url::Url::parse(&maybe_url)?; - let file_stem = script_url - .path_segments() - .and_then(std::iter::Iterator::last) - .and_then(|segment| segment.strip_suffix(".py")) - .unwrap_or("script"); - - let mut temp_file = tempfile::Builder::new() - .prefix(file_stem) - .suffix(".py") - .tempfile()?; - - // Respect cli flags and workspace settings. - let settings = GlobalSettings::resolve(global_args, filesystem); - let client = BaseClientBuilder::new() - .connectivity(settings.connectivity) - .native_tls(settings.native_tls) - .build(); - let response = client.client().get(script_url).send().await?; - let contents = response.bytes().await?; - temp_file.write_all(&contents)?; - *target = temp_file.path().into(); - Ok(Some(temp_file)) -} - #[instrument(skip_all)] async fn run(mut cli: Cli) -> Result { // Enable flag to pick up warnings generated by workspace loading. @@ -191,7 +131,6 @@ async fn run(mut cli: Cli) -> Result { }; // Parse the external command, if necessary. - let mut maybe_tempfile: Option = None; let run_command = if let Commands::Project(command) = &mut *cli.command { if let ProjectCommand::Run(uv_cli::RunArgs { command: Some(command), @@ -200,10 +139,17 @@ async fn run(mut cli: Cli) -> Result { .. }) = &mut **command { - maybe_tempfile = - resolve_script_target(command, &cli.top_level.global_args, filesystem.as_ref()) - .await?; - Some(RunCommand::from_args(command, *module, *script)?) + let settings = GlobalSettings::resolve(&cli.top_level.global_args, filesystem.as_ref()); + Some( + RunCommand::from_args( + command, + *module, + *script, + settings.connectivity, + settings.native_tls, + ) + .await?, + ) } else { None } @@ -218,8 +164,11 @@ async fn run(mut cli: Cli) -> Result { Some( RunCommand::PythonScript(script, _) | RunCommand::PythonGuiScript(script, _), ) => Pep723Script::read(&script).await?.map(Pep723Item::Script), + Some(RunCommand::PythonRemote(script, _)) => { + Pep723Metadata::read(&script).await?.map(Pep723Item::Remote) + } Some(RunCommand::PythonStdin(contents)) => { - Pep723Stdin::parse(contents)?.map(Pep723Item::Stdin) + Pep723Metadata::parse(contents)?.map(Pep723Item::Stdin) } _ => None, } @@ -277,15 +226,6 @@ async fn run(mut cli: Cli) -> Result { Printer::Default }; - // We only have a tempfile if we downloaded a remote script. - if let Some(temp_file) = maybe_tempfile.as_ref() { - writeln!( - printer.stderr(), - "Downloaded remote script to: {}", - temp_file.path().display().cyan(), - )?; - } - // Configure the `warn!` macros, which control user-facing warnings in the CLI. if globals.quiet { uv_warnings::disable(); @@ -1226,7 +1166,6 @@ async fn run(mut cli: Cli) -> Result { .await .expect("tokio threadpool exited unexpectedly"), }; - drop(maybe_tempfile); result } @@ -1471,6 +1410,7 @@ async fn run_project( let script = script.map(|script| match script { Pep723Item::Script(script) => script, Pep723Item::Stdin(_) => unreachable!("`uv remove` does not support stdin"), + Pep723Item::Remote(_) => unreachable!("`uv remove` does not support remote files"), }); commands::remove( diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index db558a455..e10918d59 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -307,7 +307,7 @@ fn run_pep723_script() -> Result<()> { ----- stdout ----- ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] @@ -321,7 +321,7 @@ fn run_pep723_script() -> Result<()> { ----- stdout ----- ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` Resolved 1 package in [TIME] "###); @@ -391,7 +391,7 @@ fn run_pep723_script() -> Result<()> { Hello, world! ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` "###); // Running a script with `--locked` should warn. @@ -402,7 +402,7 @@ fn run_pep723_script() -> Result<()> { Hello, world! ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` warning: `--locked` is a no-op for Python scripts with inline metadata, which always run in isolation "###); @@ -424,7 +424,7 @@ fn run_pep723_script() -> Result<()> { ----- stdout ----- ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` × No solution found when resolving script dependencies: ╰─▶ Because there are no versions of add and you require add, we can conclude that your requirements are unsatisfiable. "###); @@ -487,7 +487,7 @@ fn run_pep723_script_requires_python() -> Result<()> { ----- stdout ----- ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` warning: The Python request from `.python-version` resolved to Python 3.8.[X], which is incompatible with the script's Python requirement: `>=3.11` Resolved 1 package in [TIME] Prepared 1 package in [TIME] @@ -509,7 +509,7 @@ fn run_pep723_script_requires_python() -> Result<()> { hello ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` Resolved 1 package in [TIME] Installed 1 package in [TIME] + iniconfig==2.0.0 @@ -591,7 +591,7 @@ fn run_pep723_script_metadata() -> Result<()> { ----- stdout ----- ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] @@ -622,7 +622,7 @@ fn run_pep723_script_metadata() -> Result<()> { ----- stdout ----- ----- stderr ----- - Reading inline script metadata from: main.py + Reading inline script metadata from `main.py` Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] @@ -2095,7 +2095,7 @@ fn run_compiled_python_file() -> Result<()> { ----- stdout ----- ----- stderr ----- - Reading inline script metadata from: script.py + Reading inline script metadata from `script.py` Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] @@ -2258,7 +2258,7 @@ fn run_script_explicit() -> Result<()> { Hello, world! ----- stderr ----- - Reading inline script metadata from: script + Reading inline script metadata from `script` Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] @@ -2319,8 +2319,7 @@ fn run_remote_pep723_script() { Hello CI, from uv! ----- stderr ----- - Downloaded remote script to: [TEMP_PATH].py - Reading inline script metadata from: [TEMP_PATH].py + Reading inline script metadata from remote URL Resolved 4 packages in [TIME] Prepared 4 packages in [TIME] Installed 4 packages in [TIME] @@ -2386,7 +2385,7 @@ fn run_stdin_with_pep723() -> Result<()> { Hello, world! ----- stderr ----- - Reading inline script metadata from: `stdin` + Reading inline script metadata from `stdin` Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME]