Build path sources without build systems by default (#14413)

We currently treat path sources as virtual if they do not specify a
build system, which is surprising behavior. This PR updates the behavior
to treat path sources as packages unless the path source is explicitly
marked as `package = false` or its own `tool.uv.package` is set to
`false`.

Closes #12015

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
John Mumm 2025-07-16 23:17:01 +02:00 committed by Zanie Blue
parent b98ac8c224
commit ff30f14d50
7 changed files with 172 additions and 48 deletions

View file

@ -729,12 +729,14 @@ fn path_source(
})
} else {
// Determine whether the project is a package or virtual.
// If the `package` option is unset, check if `tool.uv.package` is set
// on the path source (otherwise, default to `true`).
let is_package = package.unwrap_or_else(|| {
let pyproject_path = install_path.join("pyproject.toml");
fs_err::read_to_string(&pyproject_path)
.ok()
.and_then(|contents| PyProjectToml::from_string(contents).ok())
.map(|pyproject_toml| pyproject_toml.is_package())
.and_then(|pyproject_toml| pyproject_toml.tool_uv_package())
.unwrap_or(true)
});

View file

@ -83,12 +83,7 @@ impl PyProjectToml {
/// non-package ("virtual") project.
pub fn is_package(&self) -> bool {
// If `tool.uv.package` is set, defer to that explicit setting.
if let Some(is_package) = self
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.package)
{
if let Some(is_package) = self.tool_uv_package() {
return is_package;
}
@ -96,6 +91,14 @@ impl PyProjectToml {
self.build_system.is_some()
}
/// Returns the value of `tool.uv.package` if set.
pub fn tool_uv_package(&self) -> Option<bool> {
self.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.package)
}
/// Returns `true` if the project uses a dynamic version.
pub fn is_dynamic(&self) -> bool {
self.project

View file

@ -13381,7 +13381,9 @@ fn add_path_with_no_workspace() -> Result<()> {
----- stderr -----
Resolved 2 packages in [TIME]
Audited in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ dep==0.1.0 (from file://[TEMP_DIR]/dep)
");
let pyproject_toml = context.read("pyproject.toml");
@ -13452,7 +13454,9 @@ fn add_path_outside_workspace_no_default() -> Result<()> {
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
Resolved 2 packages in [TIME]
Audited in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ dep==0.1.0 (from file://[TEMP_DIR]/external_dep)
");
let pyproject_toml = fs_err::read_to_string(workspace_toml)?;

View file

@ -7205,12 +7205,12 @@ fn lock_exclusion() -> Result<()> {
]
[package.metadata]
requires-dist = [{ name = "project", virtual = "../" }]
requires-dist = [{ name = "project", directory = "../" }]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "../" }
source = { directory = "../" }
"#
);
});
@ -7793,7 +7793,7 @@ fn lock_dev_transitive() -> Result<()> {
[package.metadata]
requires-dist = [
{ name = "baz", editable = "baz" },
{ name = "foo", virtual = "../foo" },
{ name = "foo", directory = "../foo" },
{ name = "iniconfig", specifier = ">1" },
]
@ -7815,7 +7815,7 @@ fn lock_dev_transitive() -> Result<()> {
[[package]]
name = "foo"
version = "0.1.0"
source = { virtual = "../foo" }
source = { directory = "../foo" }
[package.metadata]
@ -13651,7 +13651,7 @@ fn lock_narrowed_python_version_upper() -> Result<()> {
[[package]]
name = "dependency"
version = "0.1.0"
source = { virtual = "dependency" }
source = { directory = "dependency" }
dependencies = [
{ name = "iniconfig", marker = "python_full_version >= '3.10'" },
]
@ -13677,7 +13677,7 @@ fn lock_narrowed_python_version_upper() -> Result<()> {
]
[package.metadata]
requires-dist = [{ name = "dependency", marker = "python_full_version >= '3.10'", virtual = "dependency" }]
requires-dist = [{ name = "dependency", marker = "python_full_version >= '3.10'", directory = "dependency" }]
"#
);
});
@ -17173,10 +17173,10 @@ fn lock_implicit_virtual_project() -> Result<()> {
Ok(())
}
/// Lock a project that has a path dependency that is implicitly virtual (by way of omitting
/// `build-system`).
/// Lock a project that has a path dependency that is implicitly non-virtual (despite
/// omitting `build-system`).
#[test]
fn lock_implicit_virtual_path() -> Result<()> {
fn lock_implicit_package_path() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
@ -17243,7 +17243,7 @@ fn lock_implicit_virtual_path() -> Result<()> {
[[package]]
name = "child"
version = "0.1.0"
source = { virtual = "child" }
source = { directory = "child" }
dependencies = [
{ name = "iniconfig" },
]
@ -17281,7 +17281,7 @@ fn lock_implicit_virtual_path() -> Result<()> {
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = ">3" },
{ name = "child", virtual = "child" },
{ name = "child", directory = "child" },
]
[[package]]
@ -17317,20 +17317,21 @@ fn lock_implicit_virtual_path() -> Result<()> {
Resolved 6 packages in [TIME]
"###);
// Install from the lockfile. The virtual project should _not_ be installed.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
// Install from the lockfile. The path dependency should be installed.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
Prepared 5 packages in [TIME]
Installed 5 packages in [TIME]
+ anyio==4.3.0
+ child==0.1.0 (from file://[TEMP_DIR]/child)
+ idna==3.6
+ iniconfig==2.0.0
+ sniffio==1.3.1
"###);
");
Ok(())
}

View file

@ -5939,6 +5939,91 @@ fn sync_override_package() -> Result<()> {
~ project==0.0.0 (from file://[TEMP_DIR]/)
");
// Update the source `tool.uv` to `package = true`
let pyproject_toml = context.temp_dir.child("core").child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "core"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
package = true
"#,
)?;
// Mark the source as `package = false`.
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = ["core"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv.sources]
core = { path = "./core", package = false }
"#,
)?;
// Syncing the project should _not_ install `core`.
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
~ project==0.0.0 (from file://[TEMP_DIR]/)
");
// Remove the `package = false` mark.
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = ["core"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv.sources]
core = { path = "./core" }
"#,
)?;
// Syncing the project _should_ install `core`.
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Uninstalled 1 package in [TIME]
Installed 2 packages in [TIME]
+ core==0.1.0 (from file://[TEMP_DIR]/core)
~ project==0.0.0 (from file://[TEMP_DIR]/)
");
Ok(())
}