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 { } else {
// Determine whether the project is a package or virtual. // 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 is_package = package.unwrap_or_else(|| {
let pyproject_path = install_path.join("pyproject.toml"); let pyproject_path = install_path.join("pyproject.toml");
fs_err::read_to_string(&pyproject_path) fs_err::read_to_string(&pyproject_path)
.ok() .ok()
.and_then(|contents| PyProjectToml::from_string(contents).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) .unwrap_or(true)
}); });

View File

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

View File

@ -13381,7 +13381,9 @@ fn add_path_with_no_workspace() -> Result<()> {
----- stderr ----- ----- stderr -----
Resolved 2 packages in [TIME] 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"); 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] Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv Creating virtual environment at: .venv
Resolved 2 packages in [TIME] 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)?; let pyproject_toml = fs_err::read_to_string(workspace_toml)?;

View File

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

View File

@ -5939,6 +5939,91 @@ fn sync_override_package() -> Result<()> {
~ project==0.0.0 (from file://[TEMP_DIR]/) ~ 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(()) Ok(())
} }

View File

@ -116,8 +116,9 @@ with the default build system.
the presence of a `[build-system]` table is not required in other packages. For legacy reasons, the presence of a `[build-system]` table is not required in other packages. For legacy reasons,
if a build system is not defined, then `setuptools.build_meta:__legacy__` is used to build the if a build system is not defined, then `setuptools.build_meta:__legacy__` is used to build the
package. Packages you depend on may not explicitly declare their build system but are still package. Packages you depend on may not explicitly declare their build system but are still
installable. Similarly, if you add a dependency on a local package or install it with `uv pip`, installable. Similarly, if you [add a dependency on a local project](./dependencies.md#path)
uv will always attempt to build and install it. or install it with `uv pip`, uv will attempt to build and install it regardless of the presence
of a `[build-system]` table.
### Build system options ### Build system options

View File

@ -410,33 +410,28 @@ $ uv add ~/projects/bar/
!!! important !!! important
An [editable installation](#editable-dependencies) is not used for path dependencies by When using a directory as a path dependency, uv will attempt to build and install the target as
default. An editable installation may be requested for project directories: a package by default. See the [virtual dependency](#virtual-dependencies) documentation for
details.
```console An [editable installation](#editable-dependencies) is not used for path dependencies by default. An
$ uv add --editable ../projects/bar/ editable installation may be requested for project directories:
```
Which will result in a `pyproject.toml` with: ```console
$ uv add --editable ../projects/bar/
```
```toml title="pyproject.toml" Which will result in a `pyproject.toml` with:
[project]
dependencies = ["bar"]
[tool.uv.sources] ```toml title="pyproject.toml"
bar = { path = "../projects/bar", editable = true } [project]
``` dependencies = ["bar"]
Similarly, if a project is marked as a [non-package](./config.md#build-systems), but you'd [tool.uv.sources]
like to install it in the environment as a package, set `package = true` on the source: bar = { path = "../projects/bar", editable = true }
```
```toml title="pyproject.toml" !!! tip
[project]
dependencies = ["bar"]
[tool.uv.sources]
bar = { path = "../projects/bar", package = true }
```
For multiple packages in the same repository, [_workspaces_](./workspaces.md) may be a better For multiple packages in the same repository, [_workspaces_](./workspaces.md) may be a better
fit. fit.
@ -808,6 +803,39 @@ Or, to opt-out of using an editable dependency in a workspace:
$ uv add --no-editable ./path/foo $ uv add --no-editable ./path/foo
``` ```
## Virtual dependencies
uv allows dependencies to be "virtual", in which the dependency itself is not installed as a
[package](./config.md#project-packaging), but its dependencies are.
By default, only workspace members without build systems declared are virtual.
A dependency with a [`path` source](#path) is not virtual unless it explicitly sets
[`tool.uv.package = false`](../../reference/settings.md#package). Unlike working _in_ the dependent
project with uv, the package will be built even if a [build system](./config.md#build-systems) is
not declared.
To treat a dependency as virtual, set `package = false` on the source:
```toml title="pyproject.toml"
[project]
dependencies = ["bar"]
[tool.uv.sources]
bar = { path = "../projects/bar", package = false }
```
Similarly, if a dependency sets `tool.uv.package = false`, it can be overridden by declaring
`package = true` on the source:
```toml title="pyproject.toml"
[project]
dependencies = ["bar"]
[tool.uv.sources]
bar = { path = "../projects/bar", package = true }
```
## Dependency specifiers ## Dependency specifiers
uv uses standard uv uses standard