diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 074b16435..654fc2f77 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -28,7 +28,7 @@ use uv_fs::which::is_executable; use uv_fs::{PythonExt, Simplified, create_symlink}; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; -use uv_preview::{Preview, PreviewFeature}; +use uv_preview::Preview; use uv_python::{ EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, @@ -81,6 +81,7 @@ pub(crate) async fn run( project_dir: &Path, script: Option, command: Option, + downloaded_script: Option<&tempfile::NamedTempFile>, requirements: Vec, show_resolution: bool, lock_check: LockCheck, @@ -545,24 +546,11 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl script_interpreter } else { - // When running a target with the preview flag enabled, discover the workspace starting - // from the target's directory rather than the current working directory. - let discovery_dir: Cow<'_, Path> = - if preview.is_enabled(PreviewFeature::TargetWorkspaceDiscovery) { - if let Some(dir) = command.as_ref().and_then(RunCommand::script_dir) { - Cow::Owned(std::path::absolute(dir)?) - } else { - Cow::Borrowed(project_dir) - } - } else { - Cow::Borrowed(project_dir) - }; - let project = if let Some(package) = package.as_ref() { // We need a workspace, but we don't need to have a current package, we can be e.g. in // the root of a virtual workspace and then switch into the selected package. let project = VirtualProject::discover_with_package( - &discovery_dir, + project_dir, &DiscoveryOptions::default(), workspace_cache, package.clone(), @@ -571,7 +559,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl Some(project) } else { match VirtualProject::discover( - &discovery_dir, + project_dir, &DiscoveryOptions::default(), workspace_cache, ) @@ -1283,7 +1271,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl }; debug!("Running `{command}`"); - let mut process = command.as_command(interpreter); + let mut process = command.as_command(interpreter, downloaded_script); // Construct the `PATH` environment variable. let new_path = std::env::join_paths( @@ -1436,7 +1424,7 @@ pub(crate) enum RunCommand { /// Execute a `pythonw` script provided via `stdin`. PythonGuiStdin(Vec, Vec), /// Execute a Python script provided via a remote URL. - PythonRemote(DisplaySafeUrl, tempfile::NamedTempFile, Vec), + PythonRemote(DisplaySafeUrl, Vec), /// Execute an external command. External(OsString, Vec), /// Execute an empty command (in practice, `python` with no arguments). @@ -1476,7 +1464,11 @@ impl RunCommand { } /// Convert a [`RunCommand`] into a [`Command`]. - fn as_command(&self, interpreter: &Interpreter) -> Command { + fn as_command( + &self, + interpreter: &Interpreter, + downloaded_script: Option<&tempfile::NamedTempFile>, + ) -> Command { match self { Self::Python(args) => { let mut process = Command::new(interpreter.sys_executable()); @@ -1506,9 +1498,9 @@ impl RunCommand { process.args(args); process } - Self::PythonRemote(.., target, args) => { + Self::PythonRemote(.., args) => { let mut process = Command::new(interpreter.sys_executable()); - process.arg(target.path()); + process.arg(downloaded_script.unwrap().path()); process.args(args); process } @@ -1603,7 +1595,7 @@ impl RunCommand { } /// Return the directory containing the script, if any. - fn script_dir(&self) -> Option<&Path> { + pub(crate) fn script_dir(&self) -> Option<&Path> { let parent = match self { Self::PythonScript(target, _) | Self::PythonGuiScript(target, _) @@ -1741,9 +1733,8 @@ async fn resolve_gist_url( impl RunCommand { /// Determine the [`RunCommand`] for a given set of arguments. - pub(crate) async fn from_args( + pub(crate) fn from_args( command: &ExternalCommand, - client_builder: BaseClientBuilder<'_>, module: bool, script: bool, gui_script: bool, @@ -1775,47 +1766,8 @@ impl RunCommand { // 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 mut url = DisplaySafeUrl::parse(&target.to_string_lossy())?; - - let client = client_builder.build(); - let mut response = client - .for_host(&url) - .get(Url::from(url.clone())) - .send() - .await?; - - // If it's a Gist URL, use the GitHub API to get the raw URL. - if response.url().host_str() == Some("gist.github.com") { - url = - resolve_gist_url(DisplaySafeUrl::ref_cast(response.url()), &client_builder) - .await?; - - response = client - .for_host(&url) - .get(Url::from(url.clone())) - .send() - .await?; - } - - 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()?; - - // 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(url, file, args.to_vec())); + let url = DisplaySafeUrl::parse(&target.to_string_lossy())?; + return Ok(Self::PythonRemote(url, args.to_vec())); } } @@ -1860,6 +1812,52 @@ impl RunCommand { )) } } + + pub(crate) async fn download_remote_script( + mut url: &DisplaySafeUrl, + client_builder: &BaseClientBuilder<'_>, + ) -> anyhow::Result { + let client = client_builder.build(); + let mut response = client + .for_host(url) + .get(Url::from(url.clone())) + .send() + .await?; + + let gist_url; + // If it's a Gist URL, use the GitHub API to get the raw URL. + if response.url().host_str() == Some("gist.github.com") { + gist_url = + resolve_gist_url(DisplaySafeUrl::ref_cast(response.url()), client_builder).await?; + url = &gist_url; + + response = client + .for_host(url) + .get(Url::from(url.clone())) + .send() + .await?; + } + + 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()?; + + // 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?)?; + } + + Ok(file) + } } /// Returns `true` if the target is a ZIP archive containing a `__main__.py` file. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 8b0814c11..9f066aef3 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -70,7 +70,7 @@ pub(crate) mod settings; mod windows_exception; #[instrument(skip_all)] -async fn run(mut cli: Cli) -> Result { +async fn run(cli: Cli) -> Result { // Enable flag to pick up warnings generated by workspace loading. if cli.top_level.global_args.quiet == 0 { uv_warnings::enable(); @@ -87,31 +87,56 @@ async fn run(mut cli: Cli) -> Result { std::env::set_current_dir(directory)?; } + // Parse the external command, if necessary. + let run_command = if let Commands::Project(command) = &*cli.command + && let ProjectCommand::Run(uv_cli::RunArgs { + command: Some(ref command), + module, + script, + gui_script, + .. + }) = **command + { + Some(RunCommand::from_args(command, module, script, gui_script)?) + } else { + None + }; + + // Load environment variables not handled by Clap. + let environment = EnvironmentOptions::new()?; + + // Resolve preview flags before config discovery for decisions that affect the discovery root. + let early_preview = Preview::from_args( + settings::resolve_preview(&cli.top_level.global_args, None, &environment), + cli.top_level.global_args.no_preview, + &cli.top_level.global_args.preview_features, + ); + // Determine the project directory. // // If `--project` points to a `pyproject.toml` file, resolve to its parent directory, // since downstream code (e.g., `FilesystemOptions::find`) expects a directory. - let project_dir = cli - .top_level - .global_args - .project - .as_deref() - .map(std::path::absolute) - .transpose()? - .map(uv_fs::normalize_path_buf) - .map(|path| { - path.file_name() - .filter(|name| *name == "pyproject.toml") - .filter(|_| path.is_file()) - .and_then(|_| path.parent()) - .map(Path::to_path_buf) - .unwrap_or(path) - }) - .map(Cow::Owned) - .unwrap_or_else(|| Cow::Borrowed(&*CWD)); - - // Load environment variables not handled by Clap - let environment = EnvironmentOptions::new()?; + let project_dir = if let Some(project) = &cli.top_level.global_args.project { + let path = uv_fs::normalize_path_buf(std::path::absolute(project)?); + if let Some(name) = path.file_name() + && name == "pyproject.toml" + && path.is_file() + && let Some(parent) = path.parent() + { + Cow::Owned(parent.to_path_buf()) + } else { + Cow::Owned(path) + } + } else if let Some(run_command) = &run_command + && early_preview.is_enabled(PreviewFeature::TargetWorkspaceDiscovery) + && let Some(dir) = run_command.script_dir() + { + // When running a target with the preview flag enabled, discover the workspace starting + // from the target's directory rather than the current working directory. + Cow::Owned(std::path::absolute(dir)?) + } else { + Cow::Borrowed(&*CWD) + }; // Validate that the project directory exists if explicitly provided via --project, except for // `uv init`, which creates the project directory (separate deprecation). @@ -122,16 +147,8 @@ async fn run(mut cli: Cli) -> Result { if !skip_project_validation { if let Some(project_path) = cli.top_level.global_args.project.as_ref() { - // Resolve the preview flags until this becomes stabilized. We do - // not pass a workspace configuration as this would require reading - // from the project directory which might not exist. - let preview = Preview::from_args( - settings::resolve_preview(&cli.top_level.global_args, None, &environment), - cli.top_level.global_args.no_preview, - &cli.top_level.global_args.preview_features, - ); if !project_dir.exists() { - if preview.is_enabled(PreviewFeature::ProjectDirectoryMustExist) { + if early_preview.is_enabled(PreviewFeature::ProjectDirectoryMustExist) { bail!( "Project directory `{}` does not exist", project_path.user_display() @@ -146,7 +163,7 @@ async fn run(mut cli: Cli) -> Result { } else if !project_dir.is_dir() { // `--project path/to/pyproject.toml` is resolved to its parent above, // so this only triggers for other file types (see #18508). - if preview.is_enabled(PreviewFeature::ProjectDirectoryMustExist) { + if early_preview.is_enabled(PreviewFeature::ProjectDirectoryMustExist) { bail!( "Project path `{}` is not a directory", project_path.user_display() @@ -237,44 +254,8 @@ async fn run(mut cli: Cli) -> Result { project.combine(user).combine(system) }; - // Parse the external command, if necessary. - let run_command = if let Commands::Project(command) = &mut *cli.command { - if let ProjectCommand::Run(uv_cli::RunArgs { - command: Some(command), - module, - script, - gui_script, - .. - }) = &mut **command - { - let settings = GlobalSettings::resolve( - &cli.top_level.global_args, - filesystem.as_ref(), - &environment, - ); - let client_builder = BaseClientBuilder::new( - settings.network_settings.connectivity, - settings.network_settings.system_certs, - settings.network_settings.allow_insecure_host, - settings.preview, - settings.network_settings.read_timeout, - settings.network_settings.connect_timeout, - settings.network_settings.retries, - ) - .http_proxy(settings.network_settings.http_proxy) - .https_proxy(settings.network_settings.https_proxy) - .no_proxy(settings.network_settings.no_proxy); - Some( - RunCommand::from_args(command, client_builder, *module, *script, *gui_script) - .await?, - ) - } else { - None - } - } else { - None - }; - + let mut downloaded_script = None; + // If the target is a remote script, download it. // If the target is a PEP 723 script, parse it. let script = if let Commands::Project(command) = &*cli.command { match &**command { @@ -287,8 +268,29 @@ async fn run(mut cli: Cli) -> Result { Err(Pep723Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => None, Err(err) => return Err(err.into()), }, - Some(RunCommand::PythonRemote(url, script, _)) => { - match Pep723Metadata::read(&script).await { + Some(RunCommand::PythonRemote(url, _)) => { + let settings = GlobalSettings::resolve( + &cli.top_level.global_args, + filesystem.as_ref(), + &environment, + ); + let client_builder = BaseClientBuilder::new( + settings.network_settings.connectivity, + settings.network_settings.system_certs, + settings.network_settings.allow_insecure_host, + settings.preview, + settings.network_settings.read_timeout, + settings.network_settings.connect_timeout, + settings.network_settings.retries, + ) + .http_proxy(settings.network_settings.http_proxy) + .https_proxy(settings.network_settings.https_proxy) + .no_proxy(settings.network_settings.no_proxy); + + downloaded_script = + Some(RunCommand::download_remote_script(url, &client_builder).await?); + + match Pep723Metadata::read(downloaded_script.as_ref().unwrap()).await { Ok(Some(metadata)) => Some(Pep723Item::Remote(metadata, url.clone())), Ok(None) => None, Err(Pep723Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { @@ -1343,6 +1345,7 @@ async fn run(mut cli: Cli) -> Result { project, &project_dir, run_command, + downloaded_script.as_ref(), script, globals, cli.top_level.no_config, @@ -2014,6 +2017,7 @@ async fn run_project( project_command: Box, project_dir: &Path, command: Option, + downloaded_script: Option<&tempfile::NamedTempFile>, script: Option, globals: GlobalSettings, // TODO(zanieb): Determine a better story for passing `no_config` in here @@ -2134,6 +2138,7 @@ async fn run_project( project_dir, script, command, + downloaded_script, requirements, args.show_resolution || globals.verbose > 0, args.lock_check, diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 938e9eb3d..e60020488 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -6474,10 +6474,7 @@ fn run_only_group_and_extra_conflict() -> Result<()> { Ok(()) } -/// Test that `--preview-features target-workspace-discovery` discovers the workspace -/// from the target's directory rather than the current working directory. -#[test] -fn run_target_workspace_discovery() -> Result<()> { +fn setup_target_workspace_discovery_context() -> Result { let context = uv_test::test_context!("3.12"); // Create a workspace in a subdirectory. @@ -6494,6 +6491,8 @@ fn run_target_workspace_discovery() -> Result<()> { [build-system] requires = ["uv_build>=0.7,<10000"] build-backend = "uv_build" + + [tool.uv.workspace] "# })?; workspace @@ -6509,6 +6508,15 @@ fn run_target_workspace_discovery() -> Result<()> { " })?; + Ok(context) +} + +/// Test that `--preview-features target-workspace-discovery` discovers the workspace +/// from the target's directory rather than the current working directory. +#[test] +fn run_target_workspace_discovery() -> Result<()> { + let context = setup_target_workspace_discovery_context()?; + // Without the preview feature, running from the parent directory fails to find the workspace, // so the dependency is not installed. uv_snapshot!(context.filters(), context.run().arg("project/script.py").env_remove(EnvVars::VIRTUAL_ENV), @r#" @@ -6523,6 +6531,11 @@ fn run_target_workspace_discovery() -> Result<()> { ModuleNotFoundError: No module named 'iniconfig' "#); + // Write invalid configuration files to the cwd to verify that the + // target-workspace-discovery feature skips parsing them. + context.temp_dir.child("uv.toml").write_str("bad")?; + context.temp_dir.child("pyproject.toml").write_str("bad")?; + // With the preview feature, the workspace is discovered from the target's directory. uv_snapshot!(context.filters(), context.run().arg("--preview-features").arg("target-workspace-discovery").arg("project/script.py").env_remove(EnvVars::VIRTUAL_ENV), @" success: true @@ -6543,6 +6556,60 @@ fn run_target_workspace_discovery() -> Result<()> { Ok(()) } +/// Test that `--preview` enables target workspace discovery. +#[test] +fn run_target_workspace_discovery_preview_flag() -> Result<()> { + let context = setup_target_workspace_discovery_context()?; + + context.temp_dir.child("uv.toml").write_str("bad")?; + context.temp_dir.child("pyproject.toml").write_str("bad")?; + + uv_snapshot!(context.filters(), context.run().arg("--preview").arg("project/script.py").env_remove(EnvVars::VIRTUAL_ENV), @" + success: true + exit_code: 0 + ----- stdout ----- + success + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: project/.venv + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + foo==1.0.0 (from file://[TEMP_DIR]/project) + + iniconfig==2.0.0 + "); + + Ok(()) +} + +/// Test that `UV_PREVIEW=1` enables target workspace discovery. +#[test] +fn run_target_workspace_discovery_uv_preview_env() -> Result<()> { + let context = setup_target_workspace_discovery_context()?; + + context.temp_dir.child("uv.toml").write_str("bad")?; + context.temp_dir.child("pyproject.toml").write_str("bad")?; + + uv_snapshot!(context.filters(), context.run().env("UV_PREVIEW", "1").arg("project/script.py").env_remove(EnvVars::VIRTUAL_ENV), @" + success: true + exit_code: 0 + ----- stdout ----- + success + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: project/.venv + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + foo==1.0.0 (from file://[TEMP_DIR]/project) + + iniconfig==2.0.0 + "); + + Ok(()) +} + /// Test that `--preview-features target-workspace-discovery` works with a bare script /// filename (no directory component), which would otherwise cause `Path::parent()` to /// return an empty path. @@ -6571,6 +6638,31 @@ fn run_target_workspace_discovery_bare_script() -> Result<()> { Ok(()) } +/// `--project` should still take precedence over target workspace discovery. +#[test] +fn run_project_precedes_target_workspace_discovery() -> Result<()> { + let context = setup_target_workspace_discovery_context()?; + let missing_project = context.temp_dir.child("missing-project"); + + uv_snapshot!(context.filters(), context.run() + .env("UV_PREVIEW", "1") + .arg("--preview-features") + .arg("target-workspace-discovery") + .arg("--project") + .arg(missing_project.path()) + .arg("project/script.py") + .env_remove(EnvVars::VIRTUAL_ENV), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Project directory `missing-project` does not exist + "); + + Ok(()) +} + /// Using `--project` with a non-existent directory should warn. #[test] fn run_project_not_found() {