Skip to content

Build path sources without build systems by default #14413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/uv-distribution/src/metadata/lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});

Expand Down
15 changes: 9 additions & 6 deletions crates/uv-workspace/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,22 @@ impl PyProjectToml {
/// non-package ("virtual") project.
pub fn is_package(&self) -> bool {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left the existing logic in place in is_package (factoring out the tool.uv.package check) to constrain this change to path sources only.

// 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;
}

// Otherwise, a project is assumed to be a package if `build-system` is present.
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
Expand Down
8 changes: 6 additions & 2 deletions crates/uv/tests/it/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)?;
Expand Down
33 changes: 17 additions & 16 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "../" }
"#
);
});
Expand Down Expand Up @@ -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" },
]

Expand All @@ -7815,7 +7815,7 @@ fn lock_dev_transitive() -> Result<()> {
[[package]]
name = "foo"
version = "0.1.0"
source = { virtual = "../foo" }
source = { directory = "../foo" }

[package.metadata]

Expand Down Expand Up @@ -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'" },
]
Expand All @@ -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" }]
"#
);
});
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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" },
]
Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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(())
}
Expand Down
85 changes: 85 additions & 0 deletions crates/uv/tests/it/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
5 changes: 3 additions & 2 deletions docs/concepts/projects/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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
installable. Similarly, if you add a dependency on a local package or install it with `uv pip`,
uv will always attempt to build and install it.
installable. Similarly, if you [add a dependency on a local project](./dependencies.md#path)
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

Expand Down
70 changes: 49 additions & 21 deletions docs/concepts/projects/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,33 +410,28 @@ $ uv add ~/projects/bar/

!!! important

An [editable installation](#editable-dependencies) is not used for path dependencies by
default. An editable installation may be requested for project directories:
When using a directory as a path dependency, uv will attempt to build and install the target as
a package by default. See the [virtual dependency](#virtual-dependencies) documentation for
details.

```console
$ uv add --editable ../projects/bar/
```
An [editable installation](#editable-dependencies) is not used for path dependencies by default. An
editable installation may be requested for project directories:

Which will result in a `pyproject.toml` with:

```toml title="pyproject.toml"
[project]
dependencies = ["bar"]
```console
$ uv add --editable ../projects/bar/
```

[tool.uv.sources]
bar = { path = "../projects/bar", editable = true }
```
Which will result in a `pyproject.toml` with:

Similarly, if a project is marked as a [non-package](./config.md#build-systems), but you'd
like to install it in the environment as a package, set `package = true` on the source:
```toml title="pyproject.toml"
[project]
dependencies = ["bar"]

```toml title="pyproject.toml"
[project]
dependencies = ["bar"]
[tool.uv.sources]
bar = { path = "../projects/bar", editable = true }
```

[tool.uv.sources]
bar = { path = "../projects/bar", package = true }
```
!!! tip

For multiple packages in the same repository, [_workspaces_](./workspaces.md) may be a better
fit.
Expand Down Expand Up @@ -808,6 +803,39 @@ Or, to opt-out of using an editable dependency in a workspace:
$ 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

uv uses standard
Expand Down
Loading