diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index dfc33aab1fdde..1ef9906725ffc 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -193,6 +193,7 @@ pub enum PreviewFeature { RelocatableEnvsDefault = 1 << 24, PublishRequireNormalized = 1 << 25, Audit = 1 << 26, + ProjectDirectoryMustExist = 1 << 27, } impl PreviewFeature { @@ -226,6 +227,7 @@ impl PreviewFeature { Self::RelocatableEnvsDefault => "relocatable-envs-default", Self::PublishRequireNormalized => "publish-require-normalized", Self::Audit => "audit", + Self::ProjectDirectoryMustExist => "project-directory-must-exist", } } } @@ -272,6 +274,7 @@ impl FromStr for PreviewFeature { "relocatable-envs-default" => Self::RelocatableEnvsDefault, "publish-require-normalized" => Self::PublishRequireNormalized, "audit" => Self::Audit, + "project-directory-must-exist" => Self::ProjectDirectoryMustExist, _ => return Err(PreviewFeatureParseError), }) } @@ -517,6 +520,10 @@ mod tests { PreviewFeature::PublishRequireNormalized.as_str(), "publish-require-normalized" ); + assert_eq!( + PreviewFeature::ProjectDirectoryMustExist.as_str(), + "project-directory-must-exist" + ); } #[test] diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 99df61f27237e..5ef8169506356 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -40,7 +40,7 @@ use uv_fs::{CWD, Simplified}; #[cfg(feature = "self-update")] use uv_pep440::release_specifiers_to_ranges; use uv_pep508::VersionOrUrl; -use uv_preview::PreviewFeature; +use uv_preview::{Preview, PreviewFeature}; use uv_pypi_types::{ParsedDirectoryUrl, ParsedUrl}; use uv_python::PythonRequest; use uv_requirements::{GroupsSpecification, RequirementsSource}; @@ -102,6 +102,55 @@ async fn run(mut cli: Cli) -> Result { // Load environment variables not handled by Clap let environment = EnvironmentOptions::new()?; + // Validate that the project directory exists if explicitly provided via --project, except for + // `uv init`, which creates the project directory (separate deprecation). + let skip_project_validation = matches!( + &*cli.command, + Commands::Project(command) if matches!(**command, ProjectCommand::Init(_)) + ); + + 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 check CLI args and + // the `UV_PREVIEW` env var, but not workspace config (which requires reading from + // the project directory that may not exist). + let preview = Preview::from_args( + cli.top_level.global_args.preview || environment.preview.value == Some(true), + cli.top_level.global_args.no_preview, + &cli.top_level.global_args.preview_features, + ); + if !project_dir.exists() { + if preview.is_enabled(PreviewFeature::ProjectDirectoryMustExist) { + bail!( + "Project directory `{}` does not exist", + project_path.user_display() + ); + } + warn_user_once!( + "Project directory `{}` does not exist. \ + This will become an error in a future release. \ + Use `--preview-features project-directory-must-exist` to error on this now.", + project_path.user_display() + ); + } else if !project_dir.is_dir() { + // On Unix, this always fails downstream (e.g., "Not a directory" when + // trying to read `uv.toml`), so we bail with a clear error message. + // On Windows, this is currently non-fatal, so we only error with the + // preview flag. + if cfg!(unix) || preview.is_enabled(PreviewFeature::ProjectDirectoryMustExist) { + bail!( + "Project path `{}` is not a directory", + project_path.user_display() + ); + } + warn_user_once!( + "Project path `{}` is not a directory. Use `--preview-features project-directory-must-exist` to error on this.", + project_path.user_display() + ); + } + } + } + // The `--isolated` argument is deprecated on preview APIs, and warns on non-preview APIs. let deprecated_isolated = if cli.top_level.global_args.isolated { match &*cli.command { diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index cff25f2754341..7874cf0a90200 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -6570,3 +6570,94 @@ fn run_target_workspace_discovery_bare_script() -> Result<()> { Ok(()) } + +/// Using `--project` with a non-existent directory should warn. +#[test] +fn run_project_not_found() { + let context = uv_test::test_context!("3.12"); + + uv_snapshot!(context.filters(), context.run().arg("--project").arg("/tmp/does-not-exist-uv-test").arg("python").arg("-c").arg("print('hello')"), @" + success: true + exit_code: 0 + ----- stdout ----- + hello + + ----- stderr ----- + warning: Project directory `/tmp/does-not-exist-uv-test` does not exist. This will become an error in a future release. Use `--preview-features project-directory-must-exist` to error on this now. + "); +} + +/// Using `--project` with a non-existent directory should error with the preview flag. +#[test] +fn run_project_not_found_preview() { + let context = uv_test::test_context!("3.12"); + + uv_snapshot!(context.filters(), context.run().arg("--preview-features").arg("project-directory-must-exist").arg("--project").arg("/tmp/does-not-exist-uv-test").arg("python").arg("-c").arg("print('hello')"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Project directory `/tmp/does-not-exist-uv-test` does not exist + "); +} + +/// Using `--project` with a non-existent directory should error with `UV_PREVIEW=1`. +#[test] +fn run_project_not_found_uv_preview_env() { + let context = uv_test::test_context!("3.12"); + + uv_snapshot!(context.filters(), context.run().env("UV_PREVIEW", "1").arg("--project").arg("/tmp/does-not-exist-uv-test").arg("python").arg("-c").arg("print('hello')"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Project directory `/tmp/does-not-exist-uv-test` does not exist + "); +} + +/// Using `--project` with a file path should error on Unix (it fails downstream anyway). +#[test] +#[cfg(unix)] +fn run_project_is_file() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Create a file instead of a directory. + let file_path = context.temp_dir.child("not-a-directory"); + file_path.write_str("")?; + + uv_snapshot!(context.filters(), context.run().arg("--project").arg(file_path.path()).arg("python").arg("-c").arg("print('hello')"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Project path `not-a-directory` is not a directory + "); + + Ok(()) +} + +/// Using `--project` with a file path should warn on Windows (where it's currently non-fatal). +#[test] +#[cfg(windows)] +fn run_project_is_file() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Create a file instead of a directory. + let file_path = context.temp_dir.child("not-a-directory"); + file_path.write_str("")?; + + uv_snapshot!(context.filters(), context.run().arg("--project").arg(file_path.path()).arg("python").arg("-c").arg("print('hello')"), @" + success: true + exit_code: 0 + ----- stdout ----- + hello + + ----- stderr ----- + warning: Project path `not-a-directory` is not a directory. Use `--preview-features project-directory-must-exist` to error on this. + "); + + Ok(()) +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 9eb476cf2e728..87097ac818aeb 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -8103,6 +8103,7 @@ fn preview_features() { RelocatableEnvsDefault, PublishRequireNormalized, Audit, + ProjectDirectoryMustExist, ], }, python_preference: Managed, @@ -8373,6 +8374,7 @@ fn preview_features() { RelocatableEnvsDefault, PublishRequireNormalized, Audit, + ProjectDirectoryMustExist, ], }, python_preference: Managed,