Skip redundant project configuration parsing for uv run (#17890)

## Summary

With `PreviewFeature::TargetWorkspaceDiscovery` enabled, `uv run` was
parsing both the current directory’s project and the target script’s
project. Parsing configuration from the current directory is undesirable
in this case, and might fail with an error if the current directory is
inaccessible.

- Fixes #18687.
- Followup to #17423.

## Test Plan

- See #18687.

---------

Signed-off-by: Anders Kaseorg <andersk@mit.edu>
Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
Anders Kaseorg
2026-03-24 18:52:56 -07:00
committed by GitHub
parent a6042f67fc
commit 867e535f2a
3 changed files with 236 additions and 141 deletions
+63 -65
View File
@@ -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<Pep723Item>,
command: Option<RunCommand>,
downloaded_script: Option<&tempfile::NamedTempFile>,
requirements: Vec<RequirementsSource>,
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<u8>, Vec<OsString>),
/// Execute a Python script provided via a remote URL.
PythonRemote(DisplaySafeUrl, tempfile::NamedTempFile, Vec<OsString>),
PythonRemote(DisplaySafeUrl, Vec<OsString>),
/// Execute an external command.
External(OsString, Vec<OsString>),
/// 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<tempfile::NamedTempFile> {
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.
+77 -72
View File
@@ -70,7 +70,7 @@ pub(crate) mod settings;
mod windows_exception;
#[instrument(skip_all)]
async fn run(mut cli: Cli) -> Result<ExitStatus> {
async fn run(cli: Cli) -> Result<ExitStatus> {
// 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<ExitStatus> {
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<ExitStatus> {
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<ExitStatus> {
} 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<ExitStatus> {
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<ExitStatus> {
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<ExitStatus> {
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<ProjectCommand>,
project_dir: &Path,
command: Option<RunCommand>,
downloaded_script: Option<&tempfile::NamedTempFile>,
script: Option<Pep723Item>,
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,
+96 -4
View File
@@ -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<TestContext> {
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() {