From b20ed8ee69f00ba3320ea2661014291e1610ac1c Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 13:39:25 -0400 Subject: [PATCH 01/13] Allow project-less pyprojects in more places --- crates/uv-workspace/src/workspace.rs | 2 +- crates/uv/tests/it/run.rs | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 1349d739ca4d6..b349fac382559 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1435,7 +1435,7 @@ impl VirtualProject { options: &DiscoveryOptions, cache: &WorkspaceCache, ) -> Result { - Self::discover_impl(path, options, cache, false).await + Self::discover_impl(path, options, cache, true).await } /// Equivalent to [`VirtualProject::discover`] but consider it acceptable for diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 65d13c5272c67..5a17ddf277533 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -3083,14 +3083,17 @@ fn run_project_toml_error() -> Result<()> { init.touch()?; // `run` should fail - uv_snapshot!(context.filters(), context.run().arg("python").arg("-c").arg("import sys; print(sys.executable)"), @r###" - success: false - exit_code: 2 + uv_snapshot!(context.filters(), context.run().arg("python").arg("-c").arg("import sys; print(sys.executable)"), @r" + success: true + exit_code: 0 ----- stdout ----- + [VENV]/[BIN]/python ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` - "###); + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved in [TIME] + Audited in [TIME] + "); // `run --no-project` should not uv_snapshot!(context.filters(), context.run().arg("--no-project").arg("python").arg("-c").arg("import sys; print(sys.executable)"), @r###" From 59273d765f0ff79913a08920e23b1164e58507b9 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 13:43:57 -0400 Subject: [PATCH 02/13] Allow run to work in projectless contexts --- crates/uv-workspace/src/workspace.rs | 2 +- crates/uv/src/commands/project/run.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index b349fac382559..1349d739ca4d6 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1435,7 +1435,7 @@ impl VirtualProject { options: &DiscoveryOptions, cache: &WorkspaceCache, ) -> Result { - Self::discover_impl(path, options, cache, true).await + Self::discover_impl(path, options, cache, false).await } /// Equivalent to [`VirtualProject::discover`] but consider it acceptable for diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index f97ffdbc19351..b2b6d001cf24b 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -504,7 +504,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl .with_context(|| format!("Package `{package}` not found in workspace"))?, )) } else { - match VirtualProject::discover( + match VirtualProject::discover_defaulted( project_dir, &DiscoveryOptions::default(), &workspace_cache, From a9563554911d9ce4c51cf12a31a672b3e7aea35d Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 14:52:29 -0400 Subject: [PATCH 03/13] add virtual tests for various commands --- crates/uv/tests/it/edit.rs | 271 ++++++++++++++++++++++++++++++ crates/uv/tests/it/export.rs | 72 ++++++++ crates/uv/tests/it/python_find.rs | 174 +++++++++++++++++++ crates/uv/tests/it/run.rs | 8 +- crates/uv/tests/it/sync.rs | 72 ++++++++ crates/uv/tests/it/venv.rs | 53 ++++++ crates/uv/tests/it/version.rs | 132 +++++++++++++++ 7 files changed, 778 insertions(+), 4 deletions(-) diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index a66cd2ed58aff..5c1694075ba45 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -4442,6 +4442,277 @@ fn add_non_project() -> Result<()> { Ok(()) } +#[test] +fn add_virtual_empty() -> Result<()> { + // testing how `uv add` reacts to a pyproject with no `[project]` and nothing useful to it + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [tool.mycooltool] + wow = "someconfig" + "#})?; + + // Add normal dep (doesn't make sense) + uv_snapshot!(context.filters(), context.add() + .arg("sortedcontainers"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [tool.mycooltool] + wow = "someconfig" + "# + ); + }); + + // Add dependency-group (can make sense!) + uv_snapshot!(context.filters(), context.add() + .arg("sortedcontainers") + .arg("--group").arg("dev"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [tool.mycooltool] + wow = "someconfig" + "# + ); + }); + + Ok(()) +} + +#[test] +fn add_virtual_dependency_group() -> Result<()> { + // testing basic `uv add --group` functionality + // when the pyproject.toml is fully virtual (no `[project]`) + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#})?; + + // Add to existing group + uv_snapshot!(context.filters(), context.add() + .arg("sortedcontainers") + .arg("--group").arg("dev"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "# + ); + }); + + // Add to new group + uv_snapshot!(context.filters(), context.add() + .arg("sortedcontainers") + .arg("--group").arg("baz"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "# + ); + }); + + Ok(()) +} + +#[test] +fn remove_virtual_empty() -> Result<()> { + // testing how `uv remove` reacts to a pyproject with no `[project]` and nothing useful to it + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [tool.mycooltool] + wow = "someconfig" + + "#, + )?; + + // Remove normal dep (doesn't make sense) + uv_snapshot!(context.filters(), context.remove() + .arg("sortedcontainers"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [tool.mycooltool] + wow = "someconfig" + "# + ); + }); + + // Remove dependency-group (can make sense, but nothing there!) + uv_snapshot!(context.filters(), context.remove() + .arg("sortedcontainers") + .arg("--group").arg("dev"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [tool.mycooltool] + wow = "someconfig" + "# + ); + }); + + Ok(()) +} + +#[test] +fn remove_virtual_dependency_group() -> Result<()> { + // testing basic `uv remove --group` functionality + // when the pyproject.toml is fully virtual (no `[project]`) + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#})?; + + // Remove from group + uv_snapshot!(context.filters(), context.remove() + .arg("sortedcontainers") + .arg("--group").arg("foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "# + ); + }); + + // Remove from non-existent group + uv_snapshot!(context.filters(), context.remove() + .arg("sortedcontainers") + .arg("--group").arg("baz"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "# + ); + }); + + Ok(()) +} + /// Add the same requirement multiple times. #[test] fn add_repeat() -> Result<()> { diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index b48536f2e44fc..9e5b9293febc3 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -1143,6 +1143,78 @@ fn requirements_txt_non_project() -> Result<()> { Ok(()) } +#[test] +fn virtual_empty() -> Result<()> { + // testing how `uv export` reacts to a pyproject with no `[project]` and nothing useful to it + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [tool.mycooltool] + wow = "someconfig" + "#})?; + + uv_snapshot!(context.filters(), context.export(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + Ok(()) +} + +#[test] +fn virtual_dependency_group() -> Result<()> { + // testing basic `uv export --group` functionality + // when the pyproject.toml is fully virtual (no `[project]`) + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#})?; + + // default groups + uv_snapshot!(context.filters(), context.export(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + // explicit --group + uv_snapshot!(context.filters(), context.export() + .arg("--group").arg("bar"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + // explicit --only-group + uv_snapshot!(context.filters(), context.export() + .arg("--only-group").arg("foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + Ok(()) +} + #[cfg(feature = "git")] #[test] fn requirements_txt_https_git_credentials() -> Result<()> { diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index f438e9b4d9240..2ab23e9a07eea 100644 --- a/crates/uv/tests/it/python_find.rs +++ b/crates/uv/tests/it/python_find.rs @@ -403,6 +403,180 @@ fn python_find_project() { "###); } +#[test] +fn virtual_empty() { + // testing how `uv python find` reacts to a pyproject with no `[project]` and nothing useful to it + let context = TestContext::new_with_versions(&["3.10", "3.11", "3.12"]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [tool.mycooltool] + wow = "someconfig" + "#}) + .unwrap(); + + // Ask for the python + uv_snapshot!(context.filters(), context.python_find(), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.10] + + ----- stderr ----- + "); + + // Ask for the python (--no-project) + uv_snapshot!(context.filters(), context.python_find() + .arg("--no-project"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.10] + + ----- stderr ----- + "); + + // Ask for specific python (3.11) + uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "); + + // Create a pin + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.12` + + ----- stderr ----- + "###); + + // Ask for the python + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + // Ask for specific python (3.11) + uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "); + + // Ask for the python (--no-project) + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); +} + +#[test] +fn virtual_dependency_group() { + // testing basic `uv python find` functionality + // when the pyproject.toml is fully virtual (no `[project]`, but `[dependency-groups]` defined, + // which really shouldn't matter) + let context = TestContext::new_with_versions(&["3.10", "3.11", "3.12"]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#}) + .unwrap(); + + // Ask for the python + uv_snapshot!(context.filters(), context.python_find(), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.10] + + ----- stderr ----- + "); + + // Ask for the python (--no-project) + uv_snapshot!(context.filters(), context.python_find() + .arg("--no-project"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.10] + + ----- stderr ----- + "); + + // Ask for specific python (3.11) + uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "); + + // Create a pin + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.12` + + ----- stderr ----- + "###); + + // Ask for the python + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + // Ask for specific python (3.11) + uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "); + + // Ask for the python (--no-project) + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); +} + #[test] fn python_find_venv() { let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 5a17ddf277533..440e81fb40f69 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -3064,9 +3064,9 @@ fn run_module_stdin() { "###); } -/// When the `pyproject.toml` file is invalid. +/// Test for how run reacts to a pyproject.toml without a `[project]` #[test] -fn run_project_toml_error() -> Result<()> { +fn virtual_empty() -> Result<()> { let context = TestContext::new("3.12") .with_filtered_python_names() .with_filtered_virtualenv_bin() @@ -3082,7 +3082,7 @@ fn run_project_toml_error() -> Result<()> { let init = src.child("__init__.py"); init.touch()?; - // `run` should fail + // `run` should work fine uv_snapshot!(context.filters(), context.run().arg("python").arg("-c").arg("import sys; print(sys.executable)"), @r" success: true exit_code: 0 @@ -3095,7 +3095,7 @@ fn run_project_toml_error() -> Result<()> { Audited in [TIME] "); - // `run --no-project` should not + // `run --no-project` should also work fine uv_snapshot!(context.filters(), context.run().arg("--no-project").arg("python").arg("-c").arg("import sys; print(sys.executable)"), @r###" success: true exit_code: 0 diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 966dd41d27f8f..f0c57ee713301 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -3872,6 +3872,78 @@ fn virtual_no_build() -> Result<()> { Ok(()) } +#[test] +fn virtual_empty() -> Result<()> { + // testing how `uv sync` reacts to a pyproject with no `[project]` and nothing useful to it + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [tool.mycooltool] + wow = "someconfig" + "#})?; + + uv_snapshot!(context.filters(), context.sync(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + Ok(()) +} + +#[test] +fn virtual_dependency_group() -> Result<()> { + // testing basic `uv sync --group` functionality + // when the pyproject.toml is fully virtual (no `[project]`) + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#})?; + + // default groups + uv_snapshot!(context.filters(), context.sync(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + // explicit --group + uv_snapshot!(context.filters(), context.sync() + .arg("--group").arg("bar"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + // explicit --only-group + uv_snapshot!(context.filters(), context.sync() + .arg("--only-group").arg("foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + Ok(()) +} + #[test] fn virtual_no_build_dynamic_cached() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index bc35f949014cd..a8d38ccf7dd1b 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -189,6 +189,59 @@ fn create_venv_project_environment() -> Result<()> { Ok(()) } +#[test] +fn virtual_empty() -> Result<()> { + // testing how `uv venv` reacts to a pyproject with no `[project]` and nothing useful to it + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [tool.mycooltool] + wow = "someconfig" + "#})?; + + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "); + + Ok(()) +} + +#[test] +fn virtual_dependency_group() -> Result<()> { + // testing basic `uv venv` functionality + // when the pyproject.toml is fully virtual (no `[project]`, but `[dependency-groups]` defined) + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#})?; + + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "); + + Ok(()) +} + #[test] fn create_venv_defaults_to_cwd() { let context = TestContext::new_with_versions(&["3.12"]); diff --git a/crates/uv/tests/it/version.rs b/crates/uv/tests/it/version.rs index 1fda427058d8f..9997399a88c73 100644 --- a/crates/uv/tests/it/version.rs +++ b/crates/uv/tests/it/version.rs @@ -1952,3 +1952,135 @@ fn version_set_evil_constraints() -> Result<()> { Ok(()) } + +#[test] +fn virtual_empty() -> Result<()> { + // testing how `uv version` reacts to a pyproject with no `[project]` and nothing useful to it + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [tool.mycooltool] + wow = "someconfig" + "#})?; + + // Get version (doesn't make sense) + uv_snapshot!(context.filters(), context.version() + .arg("sortedcontainers"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [tool.mycooltool] + wow = "someconfig" + "# + ); + }); + + // Set version (can make sense, but we should still refuse?) + uv_snapshot!(context.filters(), context.version() + .arg("1.0.0"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [tool.mycooltool] + wow = "someconfig" + "# + ); + }); + + Ok(()) +} + +#[test] +fn add_virtual_dependency_group() -> Result<()> { + // testing basic `uv version` functionality + // when the pyproject.toml is fully virtual (no `[project]`) + // But at least has some dependency-group tables (shouldn't matter to this command) + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#})?; + + // Get the version (doesn't make sense) + uv_snapshot!(context.filters(), context.version(), @r" + success: true + exit_code: 0 + ----- stdout ----- + uv [VERSION] ([COMMIT] DATE) + + ----- stderr ----- + warning: Failed to read project metadata (No `project` table found in: `[TEMP_DIR]/pyproject.toml`). Running `uv self version` for compatibility. This fallback will be removed in the future; pass `--preview` to force an error. + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "# + ); + }); + + // Set the version (can make sense, we should refuse?) + uv_snapshot!(context.filters(), context.version() + .arg("1.0.0"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [dependency-groups] + foo = ["sortedcontainers"] + bar = ["iniconfig"] + dev = ["sniffio"] + "# + ); + }); + + Ok(()) +} From a1bcb9f49a3e01465464c938524cea1674694517 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 15:04:07 -0400 Subject: [PATCH 04/13] allow add to handle full virtual pyprojects --- crates/uv/src/commands/project/add.rs | 2 +- crates/uv/tests/it/edit.rs | 49 ++++++++++++++++++++------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 8e3c4a03a6920..2c4fc20ee89ce 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -237,7 +237,7 @@ pub(crate) async fn add( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover( + VirtualProject::discover_defaulted( project_dir, &DiscoveryOptions::default(), &WorkspaceCache::default(), diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 5c1694075ba45..c8474f71b44e2 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -4461,7 +4461,7 @@ fn add_virtual_empty() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + error: Project is missing a `[project]` table; add a `[project]` table to use production dependencies, or run `uv add --dev` instead "); let pyproject_toml = context.read("pyproject.toml"); @@ -4481,12 +4481,16 @@ fn add_virtual_empty() -> Result<()> { uv_snapshot!(context.filters(), context.add() .arg("sortedcontainers") .arg("--group").arg("dev"), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sortedcontainers==2.4.0 "); let pyproject_toml = context.read("pyproject.toml"); @@ -4498,6 +4502,11 @@ fn add_virtual_empty() -> Result<()> { pyproject_toml, @r#" [tool.mycooltool] wow = "someconfig" + + [dependency-groups] + dev = [ + "sortedcontainers>=2.4.0", + ] "# ); }); @@ -4523,12 +4532,17 @@ fn add_virtual_dependency_group() -> Result<()> { uv_snapshot!(context.filters(), context.add() .arg("sortedcontainers") .arg("--group").arg("dev"), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 3 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + sortedcontainers==2.4.0 "); let pyproject_toml = context.read("pyproject.toml"); @@ -4541,7 +4555,10 @@ fn add_virtual_dependency_group() -> Result<()> { [dependency-groups] foo = ["sortedcontainers"] bar = ["iniconfig"] - dev = ["sniffio"] + dev = [ + "sniffio", + "sortedcontainers>=2.4.0", + ] "# ); }); @@ -4550,12 +4567,14 @@ fn add_virtual_dependency_group() -> Result<()> { uv_snapshot!(context.filters(), context.add() .arg("sortedcontainers") .arg("--group").arg("baz"), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 3 packages in [TIME] + Audited 2 packages in [TIME] "); let pyproject_toml = context.read("pyproject.toml"); @@ -4568,7 +4587,13 @@ fn add_virtual_dependency_group() -> Result<()> { [dependency-groups] foo = ["sortedcontainers"] bar = ["iniconfig"] - dev = ["sniffio"] + dev = [ + "sniffio", + "sortedcontainers>=2.4.0", + ] + baz = [ + "sortedcontainers>=2.4.0", + ] "# ); }); From 6be4a4dfef520190ce336806c8eed578aff58e95 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 15:05:23 -0400 Subject: [PATCH 05/13] allow remove to handle full virtual pyprojects --- crates/uv/src/commands/project/remove.rs | 2 +- crates/uv/tests/it/edit.rs | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index d17cd88edb352..63f5fdaaaeeb4 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -100,7 +100,7 @@ pub(crate) async fn remove( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover( + VirtualProject::discover_defaulted( project_dir, &DiscoveryOptions::default(), &WorkspaceCache::default(), diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index c8474f71b44e2..a9d533f183a65 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -4623,7 +4623,7 @@ fn remove_virtual_empty() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + error: The dependency `sortedcontainers` could not be found in `project.dependencies` "); let pyproject_toml = context.read("pyproject.toml"); @@ -4648,7 +4648,7 @@ fn remove_virtual_empty() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + error: The dependency `sortedcontainers` could not be found in `tool.uv.dev-dependencies` or `tool.uv.dependency-groups.dev` "); let pyproject_toml = context.read("pyproject.toml"); @@ -4685,12 +4685,16 @@ fn remove_virtual_dependency_group() -> Result<()> { uv_snapshot!(context.filters(), context.remove() .arg("sortedcontainers") .arg("--group").arg("foo"), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sniffio==1.3.1 "); let pyproject_toml = context.read("pyproject.toml"); @@ -4701,7 +4705,7 @@ fn remove_virtual_dependency_group() -> Result<()> { assert_snapshot!( pyproject_toml, @r#" [dependency-groups] - foo = ["sortedcontainers"] + foo = [] bar = ["iniconfig"] dev = ["sniffio"] "# @@ -4717,7 +4721,7 @@ fn remove_virtual_dependency_group() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + error: The dependency `sortedcontainers` could not be found in `dependency-groups.baz` "); let pyproject_toml = context.read("pyproject.toml"); @@ -4728,7 +4732,7 @@ fn remove_virtual_dependency_group() -> Result<()> { assert_snapshot!( pyproject_toml, @r#" [dependency-groups] - foo = ["sortedcontainers"] + foo = [] bar = ["iniconfig"] dev = ["sniffio"] "# From 68453c144384669e03b2761cacb21ec8f481dc31 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 15:06:50 -0400 Subject: [PATCH 06/13] allow sync to handle full virtual pyprojects --- crates/uv/src/commands/project/sync.rs | 10 +++++-- crates/uv/tests/it/sync.rs | 41 ++++++++++++++++++-------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 940b3a653c049..a83fd3274236c 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -82,7 +82,7 @@ pub(crate) async fn sync( } else { // Identify the project. let project = if frozen { - VirtualProject::discover( + VirtualProject::discover_defaulted( project_dir, &DiscoveryOptions { members: MemberDiscovery::None, @@ -99,8 +99,12 @@ pub(crate) async fn sync( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) - .await? + VirtualProject::discover_defaulted( + project_dir, + &DiscoveryOptions::default(), + &workspace_cache, + ) + .await? }; // TODO(lucab): improve warning content diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index f0c57ee713301..1b31dc673b219 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -3884,12 +3884,14 @@ fn virtual_empty() -> Result<()> { "#})?; uv_snapshot!(context.filters(), context.sync(), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved in [TIME] + Audited in [TIME] "); Ok(()) @@ -3911,34 +3913,49 @@ fn virtual_dependency_group() -> Result<()> { // default groups uv_snapshot!(context.filters(), context.sync(), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + sniffio==1.3.1 "); // explicit --group uv_snapshot!(context.filters(), context.sync() .arg("--group").arg("bar"), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 "); // explicit --only-group uv_snapshot!(context.filters(), context.sync() .arg("--only-group").arg("foo"), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 2 packages in [TIME] + Installed 1 package in [TIME] + - iniconfig==2.0.0 + - sniffio==1.3.1 + + sortedcontainers==2.4.0 "); Ok(()) From f79337340da115665af41fd01a0c5316680ca323 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 15:08:09 -0400 Subject: [PATCH 07/13] allow export to handle full virtual pyprojects --- crates/uv/src/commands/project/export.rs | 10 +++-- crates/uv/tests/it/export.rs | 48 ++++++++++++++++++------ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index ac228989c6bca..52aab31ba8229 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -87,7 +87,7 @@ pub(crate) async fn export( ExportTarget::Script(script) } else { let project = if frozen { - VirtualProject::discover( + VirtualProject::discover_defaulted( project_dir, &DiscoveryOptions { members: MemberDiscovery::None, @@ -104,8 +104,12 @@ pub(crate) async fn export( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) - .await? + VirtualProject::discover_defaulted( + project_dir, + &DiscoveryOptions::default(), + &workspace_cache, + ) + .await? }; ExportTarget::Project(project) }; diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 9e5b9293febc3..e467a75a8206a 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -1155,12 +1155,15 @@ fn virtual_empty() -> Result<()> { "#})?; uv_snapshot!(context.filters(), context.export(), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved in [TIME] "); Ok(()) @@ -1182,34 +1185,55 @@ fn virtual_dependency_group() -> Result<()> { // default groups uv_snapshot!(context.filters(), context.export(), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 3 packages in [TIME] "); // explicit --group uv_snapshot!(context.filters(), context.export() .arg("--group").arg("bar"), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] --group bar + iniconfig==2.0.0 \ + --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 3 packages in [TIME] "); // explicit --only-group uv_snapshot!(context.filters(), context.export() .arg("--only-group").arg("foo"), @r" - success: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] --only-group foo + sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 3 packages in [TIME] "); Ok(()) From af55c954f293a2002852cbbc05f41f703aa4fc4a Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 15:09:45 -0400 Subject: [PATCH 08/13] allow venv to handle full virtual pyprojects --- crates/uv/src/commands/venv.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index a50c0e1553b16..0dbb33c61f68c 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -157,8 +157,12 @@ async fn venv_impl( let project = if no_project { None } else { - match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) - .await + match VirtualProject::discover_defaulted( + project_dir, + &DiscoveryOptions::default(), + &workspace_cache, + ) + .await { Ok(project) => Some(project), Err(WorkspaceError::MissingProject(_)) => None, From 2788535d6eec84795c022a0edb65c84710793b93 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 15:11:12 -0400 Subject: [PATCH 09/13] allow python pin and python find to handle full virtual pyprojects --- crates/uv/src/commands/python/find.rs | 8 ++++++-- crates/uv/src/commands/python/pin.rs | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 1e5693c655964..faeda7a6c4c52 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -43,8 +43,12 @@ pub(crate) async fn find( let project = if no_project { None } else { - match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) - .await + match VirtualProject::discover_defaulted( + project_dir, + &DiscoveryOptions::default(), + &workspace_cache, + ) + .await { Ok(project) => Some(project), Err(WorkspaceError::MissingProject(_)) => None, diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index a0af7ec41a97d..89897267604b2 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -45,8 +45,12 @@ pub(crate) async fn pin( let virtual_project = if no_project { None } else { - match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) - .await + match VirtualProject::discover_defaulted( + project_dir, + &DiscoveryOptions::default(), + &workspace_cache, + ) + .await { Ok(virtual_project) => Some(virtual_project), Err(err) => { From 82aab8e26534cd4d8b4e158661e08cdc9bb0e379 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 17 Jun 2025 15:12:34 -0400 Subject: [PATCH 10/13] allow version to handle full virtual pyprojects --- crates/uv/src/commands/project/version.rs | 2 +- crates/uv/tests/it/version.rs | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index f767441867a23..713919918a62d 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -231,7 +231,7 @@ async fn find_target(project_dir: &Path, package: Option<&PackageName>) -> Resul .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover( + VirtualProject::discover_defaulted( project_dir, &DiscoveryOptions::default(), &WorkspaceCache::default(), diff --git a/crates/uv/tests/it/version.rs b/crates/uv/tests/it/version.rs index 9997399a88c73..7f6a780cee7ae 100644 --- a/crates/uv/tests/it/version.rs +++ b/crates/uv/tests/it/version.rs @@ -1972,7 +1972,7 @@ fn virtual_empty() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + error: Missing `project.name` field in: pyproject.toml "); let pyproject_toml = context.read("pyproject.toml"); @@ -1996,7 +1996,7 @@ fn virtual_empty() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + error: Missing `project.name` field in: pyproject.toml "); let pyproject_toml = context.read("pyproject.toml"); @@ -2032,13 +2032,12 @@ fn add_virtual_dependency_group() -> Result<()> { // Get the version (doesn't make sense) uv_snapshot!(context.filters(), context.version(), @r" - success: true - exit_code: 0 + success: false + exit_code: 2 ----- stdout ----- - uv [VERSION] ([COMMIT] DATE) ----- stderr ----- - warning: Failed to read project metadata (No `project` table found in: `[TEMP_DIR]/pyproject.toml`). Running `uv self version` for compatibility. This fallback will be removed in the future; pass `--preview` to force an error. + error: Missing `project.name` field in: pyproject.toml "); let pyproject_toml = context.read("pyproject.toml"); @@ -2064,7 +2063,7 @@ fn add_virtual_dependency_group() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` + error: Missing `project.name` field in: pyproject.toml "); let pyproject_toml = context.read("pyproject.toml"); From 0fd393b46b9f1e1b22df3112adc9fff8a585c523 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 17 Sep 2025 08:35:28 -0500 Subject: [PATCH 11/13] Refactor --- .../src/metadata/dependency_groups.rs | 3 +- .../src/metadata/requires_dist.rs | 2 + crates/uv-workspace/src/lib.rs | 5 +- crates/uv-workspace/src/workspace.rs | 75 +++++++++++-------- crates/uv/src/commands/project/add.rs | 2 +- crates/uv/src/commands/project/export.rs | 4 +- crates/uv/src/commands/project/remove.rs | 2 +- crates/uv/src/commands/project/run.rs | 2 +- crates/uv/src/commands/project/sync.rs | 4 +- crates/uv/src/commands/project/version.rs | 2 +- crates/uv/src/commands/python/find.rs | 2 +- crates/uv/src/commands/python/pin.rs | 2 +- crates/uv/src/commands/venv.rs | 8 +- 13 files changed, 63 insertions(+), 50 deletions(-) diff --git a/crates/uv-distribution/src/metadata/dependency_groups.rs b/crates/uv-distribution/src/metadata/dependency_groups.rs index bf8f279bb6e49..d12e0651de5a7 100644 --- a/crates/uv-distribution/src/metadata/dependency_groups.rs +++ b/crates/uv-distribution/src/metadata/dependency_groups.rs @@ -77,6 +77,7 @@ impl SourcedDependencyGroups { SourceStrategy::Enabled => MemberDiscovery::default(), SourceStrategy::Disabled => MemberDiscovery::None, }, + ..DiscoveryOptions::default() }; // The subsequent API takes an absolute path to the dir the pyproject is in @@ -84,7 +85,7 @@ impl SourcedDependencyGroups { let absolute_pyproject_path = std::path::absolute(pyproject_path).map_err(WorkspaceError::Normalize)?; let project_dir = absolute_pyproject_path.parent().unwrap_or(&empty); - let project = VirtualProject::discover_defaulted(project_dir, &discovery, cache).await?; + let project = VirtualProject::discover(project_dir, &discovery, cache).await?; // Collect the dependency groups. let dependency_groups = diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index b7da5eef37da9..ed3fdc6f11093 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -61,6 +61,8 @@ impl RequiresDist { SourceStrategy::Enabled => MemberDiscovery::default(), SourceStrategy::Disabled => MemberDiscovery::None, }, + + ..DiscoveryOptions::default() }; let Some(project_workspace) = ProjectWorkspace::from_maybe_project_root(install_path, &discovery, cache).await? diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index c15a8b0075814..5a30721aa6312 100644 --- a/crates/uv-workspace/src/lib.rs +++ b/crates/uv-workspace/src/lib.rs @@ -1,6 +1,7 @@ pub use workspace::{ - DiscoveryOptions, Editability, MemberDiscovery, ProjectWorkspace, RequiresPythonSources, - VirtualProject, Workspace, WorkspaceCache, WorkspaceError, WorkspaceMember, + DiscoveryOptions, Editability, MemberDiscovery, ProjectDiscovery, ProjectWorkspace, + RequiresPythonSources, VirtualProject, Workspace, WorkspaceCache, WorkspaceError, + WorkspaceMember, }; pub mod dependency_groups; diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 2347a2c66f2c6..455bbb5a44523 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -94,12 +94,51 @@ pub enum MemberDiscovery { Ignore(BTreeSet), } +/// Whether a "project" must be defined via a `[project]` table. +#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)] +pub enum ProjectDiscovery { + /// The `[project]` table is optional; when missing, the target is treated as virtual. + #[default] + Optional, + /// A `[project]` table must be defined, unless `[tool.uv.workspace]` is present indicating a + /// legacy non-project workspace root. + /// + /// If neither is defined, discovery will fail. + Legacy, + /// A `[project]` table must be defined. + /// + /// If not defined, discovery will fail. + Required, +} + +impl ProjectDiscovery { + /// Whether a `[project]` table is required. + pub fn allows_implicit_workspace(&self) -> bool { + match self { + Self::Optional => true, + Self::Legacy => false, + Self::Required => false, + } + } + + /// Whether a legacy workspace root is allowed. + pub fn allows_legacy_workspace(&self) -> bool { + match self { + Self::Optional => true, + Self::Legacy => true, + Self::Required => false, + } + } +} + #[derive(Debug, Default, Clone, Hash, PartialEq, Eq)] pub struct DiscoveryOptions { /// The path to stop discovery at. pub stop_discovery_at: Option, /// The strategy to use when discovering workspace members. pub members: MemberDiscovery, + /// The strategy to use when discovering the project. + pub project: ProjectDiscovery, } pub type RequiresPythonSources = BTreeMap<(PackageName, Option), VersionSpecifiers>; @@ -1561,13 +1600,13 @@ fn is_included_in_workspace( /// A project that can be discovered. /// -/// The project could be a package within a workspace, a real workspace root, or a (legacy) -/// non-project workspace root, which can define its own dev dependencies. +/// The project could be a package within a workspace, a real workspace root, or a non-project +/// workspace root, which can define its own dev dependencies. #[derive(Debug, Clone)] pub enum VirtualProject { /// A project (which could be a workspace root or member). Project(ProjectWorkspace), - /// A (legacy) non-project workspace root. + /// A non-project workspace root. NonProject(Workspace), } @@ -1583,33 +1622,6 @@ impl VirtualProject { path: &Path, options: &DiscoveryOptions, cache: &WorkspaceCache, - ) -> Result { - Self::discover_impl(path, options, cache, false).await - } - - /// Equivalent to [`VirtualProject::discover`] but consider it acceptable for - /// both `[project]` and `[tool.uv.workspace]` to be missing. - /// - /// If they are, we act as if an empty `[tool.uv.workspace]` was found. - pub async fn discover_defaulted( - path: &Path, - options: &DiscoveryOptions, - cache: &WorkspaceCache, - ) -> Result { - Self::discover_impl(path, options, cache, true).await - } - - /// Find the current project or virtual workspace root, given the current directory. - /// - /// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`], - /// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow). - /// - /// This method requires an absolute path and panics otherwise. - async fn discover_impl( - path: &Path, - options: &DiscoveryOptions, - cache: &WorkspaceCache, - default_missing_workspace: bool, ) -> Result { assert!( path.is_absolute(), @@ -1656,6 +1668,7 @@ impl VirtualProject { .as_ref() .and_then(|tool| tool.uv.as_ref()) .and_then(|uv| uv.workspace.as_ref()) + .filter(|_| options.project.allows_legacy_workspace()) { // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace // root. @@ -1674,7 +1687,7 @@ impl VirtualProject { .await?; Ok(Self::NonProject(workspace)) - } else if default_missing_workspace { + } else if options.project.allows_implicit_workspace() { // Otherwise it's a pyproject.toml that maybe contains dependency-groups // that we want to treat like a project/workspace to handle those uniformly let project_path = std::path::absolute(project_root) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 6019d5e34e984..c449416e41c61 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -253,7 +253,7 @@ pub(crate) async fn add( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover_defaulted( + VirtualProject::discover( project_dir, &DiscoveryOptions::default(), &WorkspaceCache::default(), diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 127638243827c..a45caf4b3d834 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -89,7 +89,7 @@ pub(crate) async fn export( ExportTarget::Script(script) } else { let project = if frozen { - VirtualProject::discover_defaulted( + VirtualProject::discover( project_dir, &DiscoveryOptions { members: MemberDiscovery::None, @@ -106,7 +106,7 @@ pub(crate) async fn export( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover_defaulted( + VirtualProject::discover( project_dir, &DiscoveryOptions::default(), &workspace_cache, diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 26fbe1c470032..03a3f04fcebec 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -101,7 +101,7 @@ pub(crate) async fn remove( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover_defaulted( + VirtualProject::discover( project_dir, &DiscoveryOptions::default(), &WorkspaceCache::default(), diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index f40b9c6d5f309..f9deeb43114dd 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -545,7 +545,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl .with_context(|| format!("Package `{package}` not found in workspace"))?, )) } else { - match VirtualProject::discover_defaulted( + match VirtualProject::discover( project_dir, &DiscoveryOptions::default(), &workspace_cache, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 2beb0b2572c28..28555a195ba6a 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -98,7 +98,7 @@ pub(crate) async fn sync( } else { // Identify the project. let project = if frozen { - VirtualProject::discover_defaulted( + VirtualProject::discover( project_dir, &DiscoveryOptions { members: MemberDiscovery::None, @@ -115,7 +115,7 @@ pub(crate) async fn sync( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover_defaulted( + VirtualProject::discover( project_dir, &DiscoveryOptions::default(), &workspace_cache, diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index d6690eb1ce103..213af43eaa13c 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -367,7 +367,7 @@ async fn find_target( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover_defaulted( + VirtualProject::discover( project_dir, &DiscoveryOptions::default(), &WorkspaceCache::default(), diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 00b18b8b2e751..0833be3023530 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -45,7 +45,7 @@ pub(crate) async fn find( let project = if no_project { None } else { - match VirtualProject::discover_defaulted( + match VirtualProject::discover( project_dir, &DiscoveryOptions::default(), &workspace_cache, diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 7ecbed07831af..72537d4213691 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -45,7 +45,7 @@ pub(crate) async fn pin( let virtual_project = if no_project { None } else { - match VirtualProject::discover_defaulted( + match VirtualProject::discover( project_dir, &DiscoveryOptions::default(), &workspace_cache, diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 21ef7bd3cf387..13fe8fb517a1a 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -89,12 +89,8 @@ pub(crate) async fn venv( let project = if no_project { None } else { - match VirtualProject::discover_defaulted( - project_dir, - &DiscoveryOptions::default(), - &workspace_cache, - ) - .await + match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) + .await { Ok(project) => Some(project), Err(WorkspaceError::MissingProject(_)) => None, From 6fb02e3a531995d43642d191ac378b6ddc5db12d Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 17 Sep 2025 08:43:34 -0500 Subject: [PATCH 12/13] Update snapshot --- crates/uv-distribution/src/metadata/requires_dist.rs | 1 - crates/uv/src/commands/project/export.rs | 8 ++------ crates/uv/src/commands/project/sync.rs | 8 ++------ crates/uv/src/commands/python/find.rs | 8 ++------ crates/uv/src/commands/python/pin.rs | 8 ++------ crates/uv/tests/it/venv.rs | 1 + 6 files changed, 9 insertions(+), 25 deletions(-) diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index ed3fdc6f11093..eb0c007f992e5 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -61,7 +61,6 @@ impl RequiresDist { SourceStrategy::Enabled => MemberDiscovery::default(), SourceStrategy::Disabled => MemberDiscovery::None, }, - ..DiscoveryOptions::default() }; let Some(project_workspace) = diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index a45caf4b3d834..600374218ba88 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -106,12 +106,8 @@ pub(crate) async fn export( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover( - project_dir, - &DiscoveryOptions::default(), - &workspace_cache, - ) - .await? + VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) + .await? }; ExportTarget::Project(project) }; diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 28555a195ba6a..f6190e1cd8f72 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -115,12 +115,8 @@ pub(crate) async fn sync( .with_context(|| format!("Package `{package}` not found in workspace"))?, ) } else { - VirtualProject::discover( - project_dir, - &DiscoveryOptions::default(), - &workspace_cache, - ) - .await? + VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) + .await? }; // TODO(lucab): improve warning content diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 0833be3023530..cb832d2ced68c 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -45,12 +45,8 @@ pub(crate) async fn find( let project = if no_project { None } else { - match VirtualProject::discover( - project_dir, - &DiscoveryOptions::default(), - &workspace_cache, - ) - .await + match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) + .await { Ok(project) => Some(project), Err(WorkspaceError::MissingProject(_)) => None, diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 72537d4213691..95bf78169575f 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -45,12 +45,8 @@ pub(crate) async fn pin( let virtual_project = if no_project { None } else { - match VirtualProject::discover( - project_dir, - &DiscoveryOptions::default(), - &workspace_cache, - ) - .await + match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) + .await { Ok(virtual_project) => Some(virtual_project), Err(err) => { diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 34c66e33007d9..e4d980030e6bb 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -258,6 +258,7 @@ fn virtual_dependency_group() -> Result<()> { ----- stderr ----- Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Creating virtual environment at: .venv + warning: A virtual environment already exists at `.venv`. In the future, uv will require `--clear` to replace it Activate with: source .venv/[BIN]/activate "); From 0445f4eaa7798033f430ad426b59785294973477 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 17 Sep 2025 09:39:37 -0500 Subject: [PATCH 13/13] Require a project in `uv version` --- crates/uv/src/commands/project/version.rs | 10 ++++++++-- crates/uv/tests/it/version.rs | 10 +++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 213af43eaa13c..09a6810b73896 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -358,7 +358,10 @@ async fn find_target( VirtualProject::Project( Workspace::discover( project_dir, - &DiscoveryOptions::default(), + &DiscoveryOptions { + project: uv_workspace::ProjectDiscovery::Required, + ..DiscoveryOptions::default() + }, &WorkspaceCache::default(), ) .await @@ -369,7 +372,10 @@ async fn find_target( } else { VirtualProject::discover( project_dir, - &DiscoveryOptions::default(), + &DiscoveryOptions { + project: uv_workspace::ProjectDiscovery::Required, + ..DiscoveryOptions::default() + }, &WorkspaceCache::default(), ) .await diff --git a/crates/uv/tests/it/version.rs b/crates/uv/tests/it/version.rs index 20cc5a2f6fb17..7093b0cbb299e 100644 --- a/crates/uv/tests/it/version.rs +++ b/crates/uv/tests/it/version.rs @@ -1867,7 +1867,7 @@ fn version_get_workspace() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Missing `project.name` field in: pyproject.toml + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` "); Ok(()) @@ -2481,7 +2481,7 @@ fn virtual_empty() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Missing `project.name` field in: pyproject.toml + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` "); let pyproject_toml = context.read("pyproject.toml"); @@ -2505,7 +2505,7 @@ fn virtual_empty() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Missing `project.name` field in: pyproject.toml + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` "); let pyproject_toml = context.read("pyproject.toml"); @@ -2546,7 +2546,7 @@ fn add_virtual_dependency_group() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Missing `project.name` field in: pyproject.toml + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` "); let pyproject_toml = context.read("pyproject.toml"); @@ -2572,7 +2572,7 @@ fn add_virtual_dependency_group() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Missing `project.name` field in: pyproject.toml + error: No `project` table found in: `[TEMP_DIR]/pyproject.toml` "); let pyproject_toml = context.read("pyproject.toml");