Skip to content

Commit ff30f14

Browse files
jtfmummzanieb
andcommitted
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 <[email protected]>
1 parent b98ac8c commit ff30f14

File tree

7 files changed

+172
-48
lines changed

7 files changed

+172
-48
lines changed

crates/uv-distribution/src/metadata/lowering.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,12 +729,14 @@ fn path_source(
729729
})
730730
} else {
731731
// Determine whether the project is a package or virtual.
732+
// If the `package` option is unset, check if `tool.uv.package` is set
733+
// on the path source (otherwise, default to `true`).
732734
let is_package = package.unwrap_or_else(|| {
733735
let pyproject_path = install_path.join("pyproject.toml");
734736
fs_err::read_to_string(&pyproject_path)
735737
.ok()
736738
.and_then(|contents| PyProjectToml::from_string(contents).ok())
737-
.map(|pyproject_toml| pyproject_toml.is_package())
739+
.and_then(|pyproject_toml| pyproject_toml.tool_uv_package())
738740
.unwrap_or(true)
739741
});
740742

crates/uv-workspace/src/pyproject.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,22 @@ impl PyProjectToml {
8383
/// non-package ("virtual") project.
8484
pub fn is_package(&self) -> bool {
8585
// If `tool.uv.package` is set, defer to that explicit setting.
86-
if let Some(is_package) = self
87-
.tool
88-
.as_ref()
89-
.and_then(|tool| tool.uv.as_ref())
90-
.and_then(|uv| uv.package)
91-
{
86+
if let Some(is_package) = self.tool_uv_package() {
9287
return is_package;
9388
}
9489

9590
// Otherwise, a project is assumed to be a package if `build-system` is present.
9691
self.build_system.is_some()
9792
}
9893

94+
/// Returns the value of `tool.uv.package` if set.
95+
pub fn tool_uv_package(&self) -> Option<bool> {
96+
self.tool
97+
.as_ref()
98+
.and_then(|tool| tool.uv.as_ref())
99+
.and_then(|uv| uv.package)
100+
}
101+
99102
/// Returns `true` if the project uses a dynamic version.
100103
pub fn is_dynamic(&self) -> bool {
101104
self.project

crates/uv/tests/it/edit.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13381,7 +13381,9 @@ fn add_path_with_no_workspace() -> Result<()> {
1338113381
1338213382
----- stderr -----
1338313383
Resolved 2 packages in [TIME]
13384-
Audited in [TIME]
13384+
Prepared 1 package in [TIME]
13385+
Installed 1 package in [TIME]
13386+
+ dep==0.1.0 (from file://[TEMP_DIR]/dep)
1338513387
");
1338613388

1338713389
let pyproject_toml = context.read("pyproject.toml");
@@ -13452,7 +13454,9 @@ fn add_path_outside_workspace_no_default() -> Result<()> {
1345213454
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
1345313455
Creating virtual environment at: .venv
1345413456
Resolved 2 packages in [TIME]
13455-
Audited in [TIME]
13457+
Prepared 1 package in [TIME]
13458+
Installed 1 package in [TIME]
13459+
+ dep==0.1.0 (from file://[TEMP_DIR]/external_dep)
1345613460
");
1345713461

1345813462
let pyproject_toml = fs_err::read_to_string(workspace_toml)?;

crates/uv/tests/it/lock.rs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7205,12 +7205,12 @@ fn lock_exclusion() -> Result<()> {
72057205
]
72067206

72077207
[package.metadata]
7208-
requires-dist = [{ name = "project", virtual = "../" }]
7208+
requires-dist = [{ name = "project", directory = "../" }]
72097209

72107210
[[package]]
72117211
name = "project"
72127212
version = "0.1.0"
7213-
source = { virtual = "../" }
7213+
source = { directory = "../" }
72147214
"#
72157215
);
72167216
});
@@ -7793,7 +7793,7 @@ fn lock_dev_transitive() -> Result<()> {
77937793
[package.metadata]
77947794
requires-dist = [
77957795
{ name = "baz", editable = "baz" },
7796-
{ name = "foo", virtual = "../foo" },
7796+
{ name = "foo", directory = "../foo" },
77977797
{ name = "iniconfig", specifier = ">1" },
77987798
]
77997799

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

78207820
[package.metadata]
78217821

@@ -13651,7 +13651,7 @@ fn lock_narrowed_python_version_upper() -> Result<()> {
1365113651
[[package]]
1365213652
name = "dependency"
1365313653
version = "0.1.0"
13654-
source = { virtual = "dependency" }
13654+
source = { directory = "dependency" }
1365513655
dependencies = [
1365613656
{ name = "iniconfig", marker = "python_full_version >= '3.10'" },
1365713657
]
@@ -13677,7 +13677,7 @@ fn lock_narrowed_python_version_upper() -> Result<()> {
1367713677
]
1367813678

1367913679
[package.metadata]
13680-
requires-dist = [{ name = "dependency", marker = "python_full_version >= '3.10'", virtual = "dependency" }]
13680+
requires-dist = [{ name = "dependency", marker = "python_full_version >= '3.10'", directory = "dependency" }]
1368113681
"#
1368213682
);
1368313683
});
@@ -17173,10 +17173,10 @@ fn lock_implicit_virtual_project() -> Result<()> {
1717317173
Ok(())
1717417174
}
1717517175

17176-
/// Lock a project that has a path dependency that is implicitly virtual (by way of omitting
17177-
/// `build-system`).
17176+
/// Lock a project that has a path dependency that is implicitly non-virtual (despite
17177+
/// omitting `build-system`).
1717817178
#[test]
17179-
fn lock_implicit_virtual_path() -> Result<()> {
17179+
fn lock_implicit_package_path() -> Result<()> {
1718017180
let context = TestContext::new("3.12");
1718117181

1718217182
let pyproject_toml = context.temp_dir.child("pyproject.toml");
@@ -17243,7 +17243,7 @@ fn lock_implicit_virtual_path() -> Result<()> {
1724317243
[[package]]
1724417244
name = "child"
1724517245
version = "0.1.0"
17246-
source = { virtual = "child" }
17246+
source = { directory = "child" }
1724717247
dependencies = [
1724817248
{ name = "iniconfig" },
1724917249
]
@@ -17281,7 +17281,7 @@ fn lock_implicit_virtual_path() -> Result<()> {
1728117281
[package.metadata]
1728217282
requires-dist = [
1728317283
{ name = "anyio", specifier = ">3" },
17284-
{ name = "child", virtual = "child" },
17284+
{ name = "child", directory = "child" },
1728517285
]
1728617286

1728717287
[[package]]
@@ -17317,20 +17317,21 @@ fn lock_implicit_virtual_path() -> Result<()> {
1731717317
Resolved 6 packages in [TIME]
1731817318
"###);
1731917319

17320-
// Install from the lockfile. The virtual project should _not_ be installed.
17321-
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
17320+
// Install from the lockfile. The path dependency should be installed.
17321+
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r"
1732217322
success: true
1732317323
exit_code: 0
1732417324
----- stdout -----
1732517325

1732617326
----- stderr -----
17327-
Prepared 4 packages in [TIME]
17328-
Installed 4 packages in [TIME]
17327+
Prepared 5 packages in [TIME]
17328+
Installed 5 packages in [TIME]
1732917329
+ anyio==4.3.0
17330+
+ child==0.1.0 (from file://[TEMP_DIR]/child)
1733017331
+ idna==3.6
1733117332
+ iniconfig==2.0.0
1733217333
+ sniffio==1.3.1
17333-
"###);
17334+
");
1733417335

1733517336
Ok(())
1733617337
}

crates/uv/tests/it/sync.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5939,6 +5939,91 @@ fn sync_override_package() -> Result<()> {
59395939
~ project==0.0.0 (from file://[TEMP_DIR]/)
59405940
");
59415941

5942+
// Update the source `tool.uv` to `package = true`
5943+
let pyproject_toml = context.temp_dir.child("core").child("pyproject.toml");
5944+
pyproject_toml.write_str(
5945+
r#"
5946+
[project]
5947+
name = "core"
5948+
version = "0.1.0"
5949+
requires-python = ">=3.12"
5950+
5951+
[build-system]
5952+
requires = ["hatchling"]
5953+
build-backend = "hatchling.build"
5954+
5955+
[tool.uv]
5956+
package = true
5957+
"#,
5958+
)?;
5959+
5960+
// Mark the source as `package = false`.
5961+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
5962+
pyproject_toml.write_str(
5963+
r#"
5964+
[project]
5965+
name = "project"
5966+
version = "0.0.0"
5967+
requires-python = ">=3.12"
5968+
dependencies = ["core"]
5969+
5970+
[build-system]
5971+
requires = ["hatchling"]
5972+
build-backend = "hatchling.build"
5973+
5974+
[tool.uv.sources]
5975+
core = { path = "./core", package = false }
5976+
"#,
5977+
)?;
5978+
5979+
// Syncing the project should _not_ install `core`.
5980+
uv_snapshot!(context.filters(), context.sync(), @r"
5981+
success: true
5982+
exit_code: 0
5983+
----- stdout -----
5984+
5985+
----- stderr -----
5986+
Resolved 2 packages in [TIME]
5987+
Prepared 1 package in [TIME]
5988+
Uninstalled 1 package in [TIME]
5989+
Installed 1 package in [TIME]
5990+
~ project==0.0.0 (from file://[TEMP_DIR]/)
5991+
");
5992+
5993+
// Remove the `package = false` mark.
5994+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
5995+
pyproject_toml.write_str(
5996+
r#"
5997+
[project]
5998+
name = "project"
5999+
version = "0.0.0"
6000+
requires-python = ">=3.12"
6001+
dependencies = ["core"]
6002+
6003+
[build-system]
6004+
requires = ["hatchling"]
6005+
build-backend = "hatchling.build"
6006+
6007+
[tool.uv.sources]
6008+
core = { path = "./core" }
6009+
"#,
6010+
)?;
6011+
6012+
// Syncing the project _should_ install `core`.
6013+
uv_snapshot!(context.filters(), context.sync(), @r"
6014+
success: true
6015+
exit_code: 0
6016+
----- stdout -----
6017+
6018+
----- stderr -----
6019+
Resolved 2 packages in [TIME]
6020+
Prepared 2 packages in [TIME]
6021+
Uninstalled 1 package in [TIME]
6022+
Installed 2 packages in [TIME]
6023+
+ core==0.1.0 (from file://[TEMP_DIR]/core)
6024+
~ project==0.0.0 (from file://[TEMP_DIR]/)
6025+
");
6026+
59426027
Ok(())
59436028
}
59446029

docs/concepts/projects/config.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ with the default build system.
116116
the presence of a `[build-system]` table is not required in other packages. For legacy reasons,
117117
if a build system is not defined, then `setuptools.build_meta:__legacy__` is used to build the
118118
package. Packages you depend on may not explicitly declare their build system but are still
119-
installable. Similarly, if you add a dependency on a local package or install it with `uv pip`,
120-
uv will always attempt to build and install it.
119+
installable. Similarly, if you [add a dependency on a local project](./dependencies.md#path)
120+
or install it with `uv pip`, uv will attempt to build and install it regardless of the presence
121+
of a `[build-system]` table.
121122

122123
### Build system options
123124

docs/concepts/projects/dependencies.md

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -410,33 +410,28 @@ $ uv add ~/projects/bar/
410410

411411
!!! important
412412

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

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

420-
Which will result in a `pyproject.toml` with:
421-
422-
```toml title="pyproject.toml"
423-
[project]
424-
dependencies = ["bar"]
420+
```console
421+
$ uv add --editable ../projects/bar/
422+
```
425423

426-
[tool.uv.sources]
427-
bar = { path = "../projects/bar", editable = true }
428-
```
424+
Which will result in a `pyproject.toml` with:
429425

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

433-
```toml title="pyproject.toml"
434-
[project]
435-
dependencies = ["bar"]
430+
[tool.uv.sources]
431+
bar = { path = "../projects/bar", editable = true }
432+
```
436433

437-
[tool.uv.sources]
438-
bar = { path = "../projects/bar", package = true }
439-
```
434+
!!! tip
440435

441436
For multiple packages in the same repository, [_workspaces_](./workspaces.md) may be a better
442437
fit.
@@ -808,6 +803,39 @@ Or, to opt-out of using an editable dependency in a workspace:
808803
$ uv add --no-editable ./path/foo
809804
```
810805

806+
## Virtual dependencies
807+
808+
uv allows dependencies to be "virtual", in which the dependency itself is not installed as a
809+
[package](./config.md#project-packaging), but its dependencies are.
810+
811+
By default, only workspace members without build systems declared are virtual.
812+
813+
A dependency with a [`path` source](#path) is not virtual unless it explicitly sets
814+
[`tool.uv.package = false`](../../reference/settings.md#package). Unlike working _in_ the dependent
815+
project with uv, the package will be built even if a [build system](./config.md#build-systems) is
816+
not declared.
817+
818+
To treat a dependency as virtual, set `package = false` on the source:
819+
820+
```toml title="pyproject.toml"
821+
[project]
822+
dependencies = ["bar"]
823+
824+
[tool.uv.sources]
825+
bar = { path = "../projects/bar", package = false }
826+
```
827+
828+
Similarly, if a dependency sets `tool.uv.package = false`, it can be overridden by declaring
829+
`package = true` on the source:
830+
831+
```toml title="pyproject.toml"
832+
[project]
833+
dependencies = ["bar"]
834+
835+
[tool.uv.sources]
836+
bar = { path = "../projects/bar", package = true }
837+
```
838+
811839
## Dependency specifiers
812840

813841
uv uses standard

0 commit comments

Comments
 (0)