Build and install workspace members that are dependencies by default (#14663)

Regardless of the presence of a build system, as in
https://github.com/astral-sh/uv/pull/14413

---------

Co-authored-by: John Mumm <jtfmumm@gmail.com>
This commit is contained in:
Zanie Blue 2025-07-17 13:38:02 -05:00
parent 0077f2357f
commit cd40a34522
15 changed files with 791 additions and 67 deletions

View file

@ -306,7 +306,10 @@ impl LoweredRequirement {
},
url,
}
} else if member.pyproject_toml().is_package() {
} else if member
.pyproject_toml()
.is_package(!workspace.is_required_member(&requirement.name))
{
RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
@ -736,7 +739,8 @@ fn path_source(
fs_err::read_to_string(&pyproject_path)
.ok()
.and_then(|contents| PyProjectToml::from_string(contents).ok())
.and_then(|pyproject_toml| pyproject_toml.tool_uv_package())
// We don't require a build system for path dependencies
.map(|pyproject_toml| pyproject_toml.is_package(false))
.unwrap_or(true)
});

View file

@ -771,7 +771,7 @@ mod tests {
/// A reference list can be generated with:
/// ```text
/// $ python -c "from packaging import tags; [print(tag) for tag in tags.platform_tags()]"`
/// ````
/// ```
#[test]
fn test_platform_tags_manylinux() {
let tags = compatible_tags(&Platform::new(

View file

@ -1255,6 +1255,7 @@ impl Lock {
root: &Path,
packages: &BTreeMap<PackageName, WorkspaceMember>,
members: &[PackageName],
required_members: &BTreeSet<PackageName>,
requirements: &[Requirement],
constraints: &[Requirement],
overrides: &[Requirement],
@ -1282,7 +1283,10 @@ impl Lock {
// Validate that the member sources have not changed (e.g., that they've switched from
// virtual to non-virtual or vice versa).
for (name, member) in packages {
let expected = !member.pyproject_toml().is_package();
// We don't require a build system, if the workspace member is a dependency
let expected = !member
.pyproject_toml()
.is_package(!required_members.contains(name));
let actual = self
.find_by_name(name)
.ok()

View file

@ -66,7 +66,7 @@ pub struct PyProjectToml {
/// Used to determine whether a `build-system` section is present.
#[serde(default, skip_serializing)]
build_system: Option<serde::de::IgnoredAny>,
pub build_system: Option<serde::de::IgnoredAny>,
}
impl PyProjectToml {
@ -81,18 +81,18 @@ impl PyProjectToml {
/// Returns `true` if the project should be considered a Python package, as opposed to a
/// non-package ("virtual") project.
pub fn is_package(&self) -> bool {
pub fn is_package(&self, require_build_system: bool) -> bool {
// If `tool.uv.package` is set, defer to that explicit setting.
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()
self.build_system.is_some() || !require_build_system
}
/// Returns the value of `tool.uv.package` if set.
pub fn tool_uv_package(&self) -> Option<bool> {
fn tool_uv_package(&self) -> Option<bool> {
self.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())

View file

@ -20,7 +20,7 @@ use uv_warnings::warn_user_once;
use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
use crate::pyproject::{
Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, ToolUvWorkspace,
Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
};
type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
@ -109,6 +109,8 @@ pub struct Workspace {
install_path: PathBuf,
/// The members of the workspace.
packages: WorkspaceMembers,
/// The workspace members that are required by other members.
required_members: BTreeSet<PackageName>,
/// The sources table from the workspace `pyproject.toml`.
///
/// This table is overridden by the project sources.
@ -260,6 +262,7 @@ impl Workspace {
pyproject_toml: PyProjectToml,
) -> Option<Self> {
let mut packages = self.packages;
let member = Arc::make_mut(&mut packages).get_mut(package_name)?;
if member.root == self.install_path {
@ -279,17 +282,33 @@ impl Workspace {
// Set the `pyproject.toml` for the member.
member.pyproject_toml = pyproject_toml;
// Recompute required_members with the updated data
let required_members = Self::collect_required_members(
&packages,
&workspace_sources,
&workspace_pyproject_toml,
);
Some(Self {
pyproject_toml: workspace_pyproject_toml,
sources: workspace_sources,
packages,
required_members,
..self
})
} else {
// Set the `pyproject.toml` for the member.
member.pyproject_toml = pyproject_toml;
Some(Self { packages, ..self })
// Recompute required_members with the updated member data
let required_members =
Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml);
Some(Self {
packages,
required_members,
..self
})
}
}
@ -303,7 +322,7 @@ impl Workspace {
/// Returns the set of all workspace members.
pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
self.packages.values().filter_map(|member| {
self.packages.iter().filter_map(|(name, member)| {
let url = VerbatimUrl::from_absolute_path(&member.root)
.expect("path is valid URL")
.with_given(member.root.to_string_lossy());
@ -312,7 +331,10 @@ impl Workspace {
extras: Box::new([]),
groups: Box::new([]),
marker: MarkerTree::TRUE,
source: if member.pyproject_toml.is_package() {
source: if member
.pyproject_toml()
.is_package(!self.is_required_member(name))
{
RequirementSource::Directory {
install_path: member.root.clone().into_boxed_path(),
editable: Some(true),
@ -332,9 +354,65 @@ impl Workspace {
})
}
/// The workspace members that are required my another member of the workspace.
pub fn required_members(&self) -> &BTreeSet<PackageName> {
&self.required_members
}
/// Compute the workspace members that are required by another member of the workspace.
///
/// N.B. this checks if a workspace member is required by inspecting `tool.uv.source` entries,
/// but does not actually check if the source is _used_, which could result in false positives
/// but is easier to compute.
fn collect_required_members(
packages: &BTreeMap<PackageName, WorkspaceMember>,
sources: &BTreeMap<PackageName, Sources>,
pyproject_toml: &PyProjectToml,
) -> BTreeSet<PackageName> {
sources
.iter()
.filter(|(name, _)| {
pyproject_toml
.project
.as_ref()
.is_none_or(|project| project.name != **name)
})
.chain(
packages
.iter()
.filter_map(|(name, member)| {
member
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.as_ref())
.map(ToolUvSources::inner)
.map(move |sources| {
sources
.iter()
.filter(move |(source_name, _)| name != *source_name)
})
})
.flatten(),
)
.filter_map(|(package, sources)| {
sources
.iter()
.any(|source| matches!(source, Source::Workspace { .. }))
.then_some(package.clone())
})
.collect()
}
/// Whether a given workspace member is required by another member.
pub fn is_required_member(&self, name: &PackageName) -> bool {
self.required_members().contains(name)
}
/// Returns the set of all workspace member dependency groups.
pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
self.packages.values().filter_map(|member| {
self.packages.iter().filter_map(|(name, member)| {
let url = VerbatimUrl::from_absolute_path(&member.root)
.expect("path is valid URL")
.with_given(member.root.to_string_lossy());
@ -368,7 +446,10 @@ impl Workspace {
extras: Box::new([]),
groups: groups.into_boxed_slice(),
marker: MarkerTree::TRUE,
source: if member.pyproject_toml.is_package() {
source: if member
.pyproject_toml()
.is_package(!self.is_required_member(name))
{
RequirementSource::Directory {
install_path: member.root.clone().into_boxed_path(),
editable: Some(true),
@ -746,9 +827,16 @@ impl Workspace {
.and_then(|uv| uv.index)
.unwrap_or_default();
let required_members = Self::collect_required_members(
&workspace_members,
&workspace_sources,
&workspace_pyproject_toml,
);
Ok(Workspace {
install_path: workspace_root,
packages: workspace_members,
required_members,
sources: workspace_sources,
indexes: workspace_indexes,
pyproject_toml: workspace_pyproject_toml,
@ -1232,15 +1320,23 @@ impl ProjectWorkspace {
project.name.clone(),
current_project,
)]));
let workspace_sources = BTreeMap::default();
let required_members = Workspace::collect_required_members(
&current_project_as_members,
&workspace_sources,
project_pyproject_toml,
);
return Ok(Self {
project_root: project_path.clone(),
project_name: project.name.clone(),
workspace: Workspace {
install_path: project_path.clone(),
packages: current_project_as_members,
required_members,
// There may be package sources, but we don't need to duplicate them into the
// workspace sources.
sources: BTreeMap::default(),
sources: workspace_sources,
indexes: Vec::default(),
pyproject_toml: project_pyproject_toml.clone(),
},
@ -1692,6 +1788,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -1745,6 +1842,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -1825,6 +1923,10 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [
"bird-feeder",
"seeds"
],
"sources": {
"bird-feeder": [
{
@ -1946,6 +2048,10 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [
"bird-feeder",
"seeds"
],
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2013,6 +2119,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2147,6 +2254,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2254,6 +2362,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2375,6 +2484,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2470,6 +2580,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"sources": {},
"indexes": [],
"pyproject_toml": {

View file

@ -263,7 +263,7 @@ async fn build_impl(
.get(package)
.ok_or_else(|| anyhow::anyhow!("Package `{package}` not found in workspace"))?;
if !package.pyproject_toml().is_package() {
if !package.pyproject_toml().is_package(true) {
let name = &package.project().name;
let pyproject_toml = package.root().join("pyproject.toml");
return Err(anyhow::anyhow!(
@ -300,7 +300,7 @@ async fn build_impl(
let packages: Vec<_> = workspace
.packages()
.values()
.filter(|package| package.pyproject_toml().is_package())
.filter(|package| package.pyproject_toml().is_package(true))
.map(|package| AnnotatedSource {
source: Source::Directory(Cow::Borrowed(package.root())),
package: Some(package.project().name.clone()),

View file

@ -444,6 +444,7 @@ async fn do_lock(
// Collect the requirements, etc.
let members = target.members();
let packages = target.packages();
let required_members = target.required_members();
let requirements = target.requirements();
let overrides = target.overrides();
let constraints = target.constraints();
@ -693,6 +694,7 @@ async fn do_lock(
target.install_path(),
packages,
&members,
required_members,
&requirements,
&dependency_groups,
&constraints,
@ -906,6 +908,7 @@ impl ValidatedLock {
install_path: &Path,
packages: &BTreeMap<PackageName, WorkspaceMember>,
members: &[PackageName],
required_members: &BTreeSet<PackageName>,
requirements: &[Requirement],
dependency_groups: &BTreeMap<GroupName, Vec<Requirement>>,
constraints: &[Requirement],
@ -1117,6 +1120,7 @@ impl ValidatedLock {
install_path,
packages,
members,
required_members,
requirements,
constraints,
overrides,

View file

@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use itertools::Either;
@ -154,6 +154,18 @@ impl<'lock> LockTarget<'lock> {
}
}
/// Return the set of required workspace members, i.e., those that are required by other
/// members.
pub(crate) fn required_members(self) -> &'lock BTreeSet<PackageName> {
match self {
Self::Workspace(workspace) => workspace.required_members(),
Self::Script(_) => {
static EMPTY: BTreeSet<PackageName> = BTreeSet::new();
&EMPTY
}
}
}
/// Returns the set of supported environments for the [`LockTarget`].
pub(crate) fn environments(self) -> Option<&'lock SupportedEnvironments> {
match self {

View file

@ -117,7 +117,7 @@ pub(crate) async fn sync(
// TODO(lucab): improve warning content
// <https://github.com/astral-sh/uv/issues/7428>
if project.workspace().pyproject_toml().has_scripts()
&& !project.workspace().pyproject_toml().is_package()
&& !project.workspace().pyproject_toml().is_package(true)
{
warn_user!(
"Skipping installation of entry points (`project.scripts`) because this project is not packaged; to install entry points, set `tool.uv.package = true` or define a `build-system`"

View file

@ -10362,7 +10362,7 @@ fn add_self() -> Result<()> {
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r###"
pyproject_toml, @r#"
[project]
name = "anyio"
version = "0.1.0"
@ -10377,7 +10377,7 @@ fn add_self() -> Result<()> {
[tool.uv.sources]
anyio = { workspace = true }
"###
"#
);
});
@ -10398,7 +10398,7 @@ fn add_self() -> Result<()> {
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r###"
pyproject_toml, @r#"
[project]
name = "anyio"
version = "0.1.0"
@ -10418,7 +10418,7 @@ fn add_self() -> Result<()> {
dev = [
"anyio[types]",
]
"###
"#
);
});
@ -13173,7 +13173,9 @@ fn add_path_with_existing_workspace() -> Result<()> {
----- stderr -----
Added `dep` to workspace members
Resolved 3 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");
@ -13250,7 +13252,9 @@ fn add_path_with_workspace() -> Result<()> {
----- stderr -----
Added `dep` to workspace members
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");
@ -13316,7 +13320,9 @@ fn add_path_within_workspace_defaults_to_workspace() -> Result<()> {
----- stderr -----
Added `dep` to workspace members
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");

View file

@ -12064,10 +12064,6 @@ fn lock_remove_member() -> Result<()> {
requires-python = ">=3.12"
dependencies = ["leaf"]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
[tool.uv.workspace]
members = ["leaf"]
@ -12130,7 +12126,7 @@ fn lock_remove_member() -> Result<()> {
[[package]]
name = "leaf"
version = "0.1.0"
source = { virtual = "leaf" }
source = { editable = "leaf" }
dependencies = [
{ name = "anyio" },
]
@ -12141,13 +12137,13 @@ fn lock_remove_member() -> Result<()> {
[[package]]
name = "project"
version = "0.1.0"
source = { editable = "." }
source = { virtual = "." }
dependencies = [
{ name = "leaf" },
]
[package.metadata]
requires-dist = [{ name = "leaf", virtual = "leaf" }]
requires-dist = [{ name = "leaf", editable = "leaf" }]
[[package]]
name = "sniffio"
@ -12162,16 +12158,124 @@ fn lock_remove_member() -> Result<()> {
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
"###);
");
// Remove the member.
// Remove the member as a dependency (retain it as a workspace member)
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[tool.uv.workspace]
members = ["leaf"]
[tool.uv.sources]
leaf = { workspace = true }
"#,
)?;
// Re-run with `--locked`. This should fail.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
");
// Re-run without `--locked`.
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
");
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
members = [
"leaf",
"project",
]
[[package]]
name = "anyio"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" },
]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "leaf"
version = "0.1.0"
source = { editable = "leaf" }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [{ name = "anyio", specifier = ">3" }]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
"#
);
});
// Remove the member entirely
pyproject_toml.write_str(
r#"
[project]
@ -12238,7 +12342,7 @@ fn lock_remove_member() -> Result<()> {
/// This test would fail if we didn't write the list of workspace members to the lockfile, since
/// we wouldn't be able to determine that a new member was added.
#[test]
fn lock_add_member() -> Result<()> {
fn lock_add_member_with_build_system() -> Result<()> {
let context = TestContext::new("3.12");
// Create a workspace, but don't add the member.
@ -12449,6 +12553,339 @@ fn lock_add_member() -> Result<()> {
Ok(())
}
#[test]
fn lock_add_member_without_build_system() -> Result<()> {
let context = TestContext::new("3.12");
// Create a workspace, but don't add the member.
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[tool.uv.workspace]
members = []
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
"###);
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
"#
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
"###);
// Create a workspace member.
let leaf = context.temp_dir.child("leaf");
leaf.child("pyproject.toml").write_str(
r#"
[project]
name = "leaf"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio>3"]
"#,
)?;
// Add the member to the workspace, but not as a dependency of the root.
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[tool.uv.workspace]
members = ["leaf"]
"#,
)?;
// Re-run with `--locked`. This should fail.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
");
// Re-run with `--offline`. This should also fail, during the resolve phase.
uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @r###"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because anyio was not found in the cache and leaf depends on anyio>3, we can conclude that leaf's requirements are unsatisfiable.
And because your workspace requires leaf, we can conclude that your workspace's requirements are unsatisfiable.
hint: Packages were unavailable because the network was disabled. When the network is disabled, registry packages may only be read from the cache.
"###);
// Re-run without `--locked`.
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
Added anyio v4.3.0
Added idna v3.6
Added leaf v0.1.0
Added sniffio v1.3.1
"###);
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
"###);
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
members = [
"leaf",
"project",
]
[[package]]
name = "anyio"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" },
]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "leaf"
version = "0.1.0"
source = { virtual = "leaf" }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [{ name = "anyio", specifier = ">3" }]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
"#
);
});
// Add the member to the workspace, as a dependency of the root.
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["leaf"]
[tool.uv.workspace]
members = ["leaf"]
[tool.uv.sources]
leaf = { workspace = true }
"#,
)?;
// Re-run with `--locked`. This should fail.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
");
// Re-run without `--locked`.
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
");
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
");
let lock = context.read("uv.lock");
// It should change from a virtual to an editable source
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
members = [
"leaf",
"project",
]
[[package]]
name = "anyio"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" },
]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "leaf"
version = "0.1.0"
source = { editable = "leaf" }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [{ name = "anyio", specifier = ">3" }]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "leaf" },
]
[package.metadata]
requires-dist = [{ name = "leaf", editable = "leaf" }]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
"#
);
});
Ok(())
}
/// Lock a `pyproject.toml`, then add a dependency that's already included in the resolution.
/// In theory, we shouldn't need to re-resolve, but based on our current strategy, we don't accept
/// the existing lockfile.

View file

@ -1094,18 +1094,19 @@ fn extra_unconditional() -> Result<()> {
"###);
// This is fine because we are only enabling one
// extra, and thus, there is no conflict.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==4.1.0
+ idna==3.6
+ proxy1==0.1.0 (from file://[TEMP_DIR]/proxy1)
+ sniffio==1.3.1
"###);
");
// And same thing for the other extra.
root_pyproject_toml.write_str(
@ -1215,18 +1216,19 @@ fn extra_unconditional_non_conflicting() -> Result<()> {
// `uv sync` wasn't correctly propagating extras in a way
// that would satisfy the conflict markers that got added
// to the `proxy1[extra1]` dependency.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==4.1.0
+ idna==3.6
+ proxy1==0.1.0 (from file://[TEMP_DIR]/proxy1)
+ sniffio==1.3.1
"###);
");
Ok(())
}
@ -1301,16 +1303,17 @@ fn extra_unconditional_in_optional() -> Result<()> {
"###);
// This should install `sortedcontainers==2.3.0`.
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x1"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x1"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ proxy1==0.1.0 (from file://[TEMP_DIR]/proxy1)
+ sortedcontainers==2.3.0
"###);
");
// This should install `sortedcontainers==2.4.0`.
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--extra=x2"), @r###"
@ -4460,19 +4463,20 @@ conflicts = [
error: Extra `x2` is not defined in the project's `optional-dependencies` table
"###);
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 7 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ proxy1==0.1.0 (from file://[TEMP_DIR]/proxy1)
+ sniffio==1.3.1
"###);
");
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
@ -4558,14 +4562,14 @@ conflicts = [
requires-dist = [
{ name = "anyio", specifier = ">=4" },
{ name = "idna", marker = "extra == 'x1'", specifier = "==3.6" },
{ name = "proxy1", virtual = "proxy1" },
{ name = "proxy1", editable = "proxy1" },
]
provides-extras = ["x1"]
[[package]]
name = "proxy1"
version = "0.1.0"
source = { virtual = "proxy1" }
source = { editable = "proxy1" }
[package.optional-dependencies]
x2 = [

View file

@ -15772,18 +15772,18 @@ fn project_and_group_workspace_inherit() -> Result<()> {
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --group packages/mysubproject/pyproject.toml:foo
-e file://[TEMP_DIR]/packages/pytest
# via mysubproject (packages/mysubproject/pyproject.toml:foo)
-e file://[TEMP_DIR]/packages/sniffio
# via
# mysubproject (packages/mysubproject/pyproject.toml:foo)
# anyio
anyio==4.3.0
# via mysubproject (packages/mysubproject/pyproject.toml:foo)
idna==3.6
# via anyio
iniconfig==2.0.0
# via mysubproject (packages/mysubproject/pyproject.toml:foo)
pytest @ file://[TEMP_DIR]/packages/pytest
# via mysubproject (packages/mysubproject/pyproject.toml:foo)
sniffio @ file://[TEMP_DIR]/packages/sniffio
# via
# mysubproject (packages/mysubproject/pyproject.toml:foo)
# anyio
----- stderr -----
Resolved 5 packages in [TIME]

View file

@ -3565,6 +3565,101 @@ fn sync_ignore_extras_check_when_no_provides_extras() -> Result<()> {
Ok(())
}
#[test]
fn sync_workspace_members_with_transitive_dependencies() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[tool.uv.workspace]
members = [
"packages/*",
]
"#,
)?;
let packages = context.temp_dir.child("packages");
packages.create_dir_all()?;
// Create three workspace members with transitive dependency from
// pkg-c -> pkg-b -> pkg-a
let pkg_a = packages.child("pkg-a");
pkg_a.create_dir_all()?;
let pkg_a_pyproject_toml = pkg_a.child("pyproject.toml");
pkg_a_pyproject_toml.write_str(
r#"
[project]
name = "pkg-a"
version = "0.0.1"
requires-python = ">=3.12"
dependencies = ["anyio"]
"#,
)?;
let pkg_b = packages.child("pkg-b");
pkg_b.create_dir_all()?;
let pkg_b_pyproject_toml = pkg_b.child("pyproject.toml");
pkg_b_pyproject_toml.write_str(
r#"
[project]
name = "pkg-b"
version = "0.0.1"
requires-python = ">=3.12"
dependencies = ["pkg-a"]
[tool.uv.sources]
pkg-a = { workspace = true }
"#,
)?;
let pkg_c = packages.child("pkg-c");
pkg_c.create_dir_all()?;
let pkg_c_pyproject_toml = pkg_c.child("pyproject.toml");
pkg_c_pyproject_toml.write_str(
r#"
[project]
name = "pkg-c"
version = "0.0.1"
requires-python = ">=3.12"
dependencies = ["pkg-b"]
[tool.uv.sources]
pkg-b = { workspace = true }
"#,
)?;
// Syncing should build the two transitive dependencies pkg-a and pkg-b,
// but not pkg-c, which is not a dependency.
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Prepared 5 packages in [TIME]
Installed 5 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ pkg-a==0.0.1 (from file://[TEMP_DIR]/packages/pkg-a)
+ pkg-b==0.0.1 (from file://[TEMP_DIR]/packages/pkg-b)
+ sniffio==1.3.1
");
// The lockfile should be valid.
uv_snapshot!(context.filters(), context.lock().arg("--check"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
");
Ok(())
}
#[test]
fn sync_non_existent_extra_workspace_member() -> Result<()> {
let context = TestContext::new("3.12");
@ -3626,9 +3721,10 @@ fn sync_non_existent_extra_workspace_member() -> Result<()> {
----- stderr -----
Resolved 5 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 4 packages in [TIME]
+ anyio==4.3.0
+ child==0.1.0 (from file://[TEMP_DIR]/child)
+ idna==3.6
+ sniffio==1.3.1
");

View file

@ -808,9 +808,9 @@ $ uv add --no-editable ./path/foo
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.
By default, dependencies are never virtual.
A dependency with a [`path` source](#path) is not virtual unless it explicitly sets
A dependency with a [`path` source](#path) can be virtual if 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.
@ -825,8 +825,8 @@ dependencies = ["bar"]
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:
If a dependency sets `tool.uv.package = false`, it can be overridden by declaring `package = true`
on the source:
```toml title="pyproject.toml"
[project]
@ -836,6 +836,52 @@ dependencies = ["bar"]
bar = { path = "../projects/bar", package = true }
```
Similarly, a dependency with a [`workspace` source](#workspace-member) can be virtual if it
explicitly sets [`tool.uv.package = false`](../../reference/settings.md#package). The workspace
member will be built even if a [build system](./config.md#build-systems) is not declared.
Workspace members that are _not_ dependencies can be virtual by default, e.g., if the parent
`pyproject.toml` is:
```toml title="pyproject.toml"
[project]
name = "parent"
version = "1.0.0"
dependencies = []
[tool.uv.workspace]
members = ["child"]
```
And the child `pyproject.toml` excluded a build system:
```toml title="pyproject.toml"
[project]
name = "child"
version = "1.0.0"
dependencies = ["anyio"]
```
Then the `child` workspace member would not be installed, but the transitive dependency `anyio`
would be.
In contrast, if the parent declared a dependency on `child`:
```toml title="pyproject.toml"
[project]
name = "parent"
version = "1.0.0"
dependencies = ["child"]
[tool.uv.sources]
child = { workspace = true }
[tool.uv.workspace]
members = ["child"]
```
Then `child` would be built and installed.
## Dependency specifiers
uv uses standard