diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 54782c083..c05ac4779 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -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) }); diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 124a62881..aa64c601e 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -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 { + 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 diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index ccc0cabf2..70b8d6e50 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -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)?; diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index faf37a83a..75d81b4c0 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -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(()) } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 3544f1961..bb3546e22 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -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(()) } diff --git a/docs/concepts/projects/config.md b/docs/concepts/projects/config.md index 8efb667a1..34b62c01a 100644 --- a/docs/concepts/projects/config.md +++ b/docs/concepts/projects/config.md @@ -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 diff --git a/docs/concepts/projects/dependencies.md b/docs/concepts/projects/dependencies.md index 022db4d7e..bf11e7174 100644 --- a/docs/concepts/projects/dependencies.md +++ b/docs/concepts/projects/dependencies.md @@ -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: +```console +$ uv add --editable ../projects/bar/ +``` - ```toml title="pyproject.toml" - [project] - dependencies = ["bar"] +Which will result in a `pyproject.toml` with: - [tool.uv.sources] - bar = { path = "../projects/bar", editable = true } - ``` +```toml title="pyproject.toml" +[project] +dependencies = ["bar"] - 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: +[tool.uv.sources] +bar = { path = "../projects/bar", editable = true } +``` - ```toml title="pyproject.toml" - [project] - dependencies = ["bar"] - - [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. @@ -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