Allow editable = false for workspace sources (#15708)

## Summary

This ended up being a bit more complex, similar to `package = false`,
because we need to understand the editable status _globally_ across the
workspace based on the packages that depend on it.

Closes https://github.com/astral-sh/uv/issues/15686.
This commit is contained in:
Charlie Marsh 2025-09-07 11:41:17 -04:00 committed by GitHub
parent 97777cda66
commit e84c9231aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 713 additions and 132 deletions

View file

@ -306,22 +306,24 @@ impl LoweredRequirement {
},
url,
}
} else if member
.pyproject_toml()
.is_package(!workspace.is_required_member(&requirement.name))
{
RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable: Some(true),
r#virtual: Some(false),
}
} else {
RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable: Some(false),
r#virtual: Some(true),
let value = workspace.required_members().get(&requirement.name);
let is_required_member = value.is_some();
let editability = value.copied().flatten();
if member.pyproject_toml().is_package(!is_required_member) {
RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable: Some(editability.unwrap_or(true)),
r#virtual: Some(false),
}
} else {
RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable: Some(false),
r#virtual: Some(true),
}
}
};
(source, marker)

View file

@ -47,7 +47,7 @@ use uv_pypi_types::{
use uv_redacted::DisplaySafeUrl;
use uv_small_str::SmallString;
use uv_types::{BuildContext, HashStrategy};
use uv_workspace::WorkspaceMember;
use uv_workspace::{Editability, WorkspaceMember};
use crate::fork_strategy::ForkStrategy;
pub(crate) use crate::lock::export::PylockTomlPackage;
@ -1443,7 +1443,7 @@ impl Lock {
root: &Path,
packages: &BTreeMap<PackageName, WorkspaceMember>,
members: &[PackageName],
required_members: &BTreeSet<PackageName>,
required_members: &BTreeMap<PackageName, Editability>,
requirements: &[Requirement],
constraints: &[Requirement],
overrides: &[Requirement],
@ -1471,17 +1471,37 @@ 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 {
// 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()
.flatten()
.map(|package| matches!(package.id.source, Source::Virtual(_)));
if actual != Some(expected) {
return Ok(SatisfiesResult::MismatchedVirtual(name.clone(), expected));
let source = self.find_by_name(name).ok().flatten();
// Determine whether the member was required by any other member.
let value = required_members.get(name);
let is_required_member = value.is_some();
let editability = value.copied().flatten();
// Verify that the member is virtual (or not).
let expected_virtual = !member.pyproject_toml().is_package(!is_required_member);
let actual_virtual =
source.map(|package| matches!(package.id.source, Source::Virtual(..)));
if actual_virtual != Some(expected_virtual) {
return Ok(SatisfiesResult::MismatchedVirtual(
name.clone(),
expected_virtual,
));
}
// Verify that the member is editable (or not).
let expected_editable = if expected_virtual {
false
} else {
editability.unwrap_or(true)
};
let actual_editable =
source.map(|package| matches!(package.id.source, Source::Editable(..)));
if actual_editable != Some(expected_editable) {
return Ok(SatisfiesResult::MismatchedEditable(
name.clone(),
expected_editable,
));
}
}
@ -1995,6 +2015,8 @@ pub enum SatisfiesResult<'lock> {
MismatchedMembers(BTreeSet<PackageName>, &'lock BTreeSet<PackageName>),
/// A workspace member switched from virtual to non-virtual or vice versa.
MismatchedVirtual(PackageName, bool),
/// A workspace member switched from editable to non-editable or vice versa.
MismatchedEditable(PackageName, bool),
/// A source tree switched from dynamic to non-dynamic or vice versa.
MismatchedDynamic(&'lock PackageName, bool),
/// The lockfile uses a different set of version for its workspace members.

View file

@ -1,6 +1,6 @@
pub use workspace::{
DiscoveryOptions, MemberDiscovery, ProjectWorkspace, RequiresPythonSources, VirtualProject,
Workspace, WorkspaceCache, WorkspaceError, WorkspaceMember,
DiscoveryOptions, Editability, MemberDiscovery, ProjectWorkspace, RequiresPythonSources,
VirtualProject, Workspace, WorkspaceCache, WorkspaceError, WorkspaceMember,
};
pub mod dependency_groups;

View file

@ -1161,6 +1161,8 @@ pub enum Source {
/// When set to `false`, the package will be fetched from the remote index, rather than
/// included as a workspace package.
workspace: bool,
/// Whether the package should be installed as editable. Defaults to `true`.
editable: Option<bool>,
#[serde(
skip_serializing_if = "uv_pep508::marker::ser::is_empty",
serialize_with = "uv_pep508::marker::ser::serialize",
@ -1498,11 +1500,6 @@ impl<'de> Deserialize<'de> for Source {
"cannot specify both `workspace` and `branch`",
));
}
if editable.is_some() {
return Err(serde::de::Error::custom(
"cannot specify both `workspace` and `editable`",
));
}
if package.is_some() {
return Err(serde::de::Error::custom(
"cannot specify both `workspace` and `package`",
@ -1511,6 +1508,7 @@ impl<'de> Deserialize<'de> for Source {
return Ok(Self::Workspace {
workspace,
editable,
marker,
extra,
group,
@ -1550,10 +1548,6 @@ pub enum SourceError {
"`{0}` did not resolve to a local directory, but the `--editable` flag was provided. Editable installs are only supported for local directories."
)]
UnusedEditable(String),
#[error(
"Workspace dependency `{0}` was marked as `--no-editable`, but workspace dependencies are always added in editable mode. Pass `--no-editable` to `uv sync` or `uv run` to install workspace dependencies in non-editable mode."
)]
UnusedNoEditable(String),
#[error("Failed to resolve absolute path")]
Absolute(#[from] std::io::Error),
#[error("Path contains invalid characters: `{}`", _0.display())]
@ -1623,13 +1617,8 @@ impl Source {
}
}
if workspace {
// If a workspace source is added with `--no-editable`, error.
if editable == Some(false) {
return Err(SourceError::UnusedNoEditable(name.to_string()));
}
} else {
// If we resolved a non-path source, and user specified an `--editable` flag, error.
// If we resolved a non-path source, and user specified an `--editable` flag, error.
if !workspace {
if !matches!(source, RequirementSource::Directory { .. }) {
if editable == Some(true) {
return Err(SourceError::UnusedEditable(name.to_string()));
@ -1643,6 +1632,7 @@ impl Source {
RequirementSource::Registry { .. } | RequirementSource::Directory { .. } => {
Ok(Some(Self::Workspace {
workspace: true,
editable,
marker: MarkerTree::TRUE,
extra: None,
group: None,

View file

@ -56,7 +56,7 @@ pub enum WorkspaceError {
NonWorkspace(PathBuf),
#[error("Nested workspaces are not supported, but workspace member (`{}`) has a `uv.workspace` table", _0.simplified_display())]
NestedWorkspace(PathBuf),
#[error("Two workspace members are both named: `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
#[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
DuplicatePackage {
name: PackageName,
first: PathBuf,
@ -64,6 +64,11 @@ pub enum WorkspaceError {
},
#[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
DynamicNotAllowed(&'static str),
#[error(
"Workspace member `{}` was requested as both `editable = true` and `editable = false`",
_0
)]
EditableConflict(PackageName),
#[error("Failed to find directories for glob: `{0}`")]
Pattern(String, #[source] PatternError),
// Syntax and other errors.
@ -98,6 +103,8 @@ pub struct DiscoveryOptions {
pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
pub type Editability = Option<bool>;
/// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`].
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
@ -109,8 +116,9 @@ 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 workspace members that are required by other members, and whether they were requested
/// as editable.
required_members: BTreeMap<PackageName, Editability>,
/// The sources table from the workspace `pyproject.toml`.
///
/// This table is overridden by the project sources.
@ -255,15 +263,16 @@ impl Workspace {
/// Set the [`ProjectWorkspace`] for a given workspace member.
///
/// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
#[must_use]
pub fn with_pyproject_toml(
self,
package_name: &PackageName,
pyproject_toml: PyProjectToml,
) -> Option<Self> {
) -> Result<Option<Self>, WorkspaceError> {
let mut packages = self.packages;
let member = Arc::make_mut(&mut packages).get_mut(package_name)?;
let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
return Ok(None);
};
if member.root == self.install_path {
// If the member is also the workspace root, update _both_ the member entry and the
@ -287,28 +296,28 @@ impl Workspace {
&packages,
&workspace_sources,
&workspace_pyproject_toml,
);
)?;
Some(Self {
Ok(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;
// Recompute required_members with the updated member data
let required_members =
Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml);
Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml)?;
Some(Self {
Ok(Some(Self {
packages,
required_members,
..self
})
}))
}
}
@ -337,7 +346,13 @@ impl Workspace {
{
RequirementSource::Directory {
install_path: member.root.clone().into_boxed_path(),
editable: Some(true),
editable: Some(
self.required_members
.get(name)
.copied()
.flatten()
.unwrap_or(true),
),
r#virtual: Some(false),
url,
}
@ -355,11 +370,12 @@ impl Workspace {
}
/// The workspace members that are required my another member of the workspace.
pub fn required_members(&self) -> &BTreeSet<PackageName> {
pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
&self.required_members
}
/// Compute the workspace members that are required by another member of the workspace.
/// Compute the workspace members that are required by another member of the workspace, and
/// determine whether they should be installed as editable or non-editable.
///
/// 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
@ -368,8 +384,10 @@ impl Workspace {
packages: &BTreeMap<PackageName, WorkspaceMember>,
sources: &BTreeMap<PackageName, Sources>,
pyproject_toml: &PyProjectToml,
) -> BTreeSet<PackageName> {
sources
) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
let mut required_members = BTreeMap::new();
for (package, sources) in sources
.iter()
.filter(|(name, _)| {
pyproject_toml
@ -396,18 +414,29 @@ impl Workspace {
})
.flatten(),
)
.filter_map(|(package, sources)| {
sources
.iter()
.any(|source| matches!(source, Source::Workspace { .. }))
.then_some(package.clone())
})
.collect()
{
for source in sources.iter() {
let Source::Workspace { editable, .. } = &source else {
continue;
};
let existing = required_members.insert(package.clone(), *editable);
if let Some(Some(existing)) = existing {
if let Some(editable) = editable {
// If there are conflicting `editable` values, raise an error.
if existing != *editable {
return Err(WorkspaceError::EditableConflict(package.clone()));
}
}
}
}
}
Ok(required_members)
}
/// Whether a given workspace member is required by another member.
pub fn is_required_member(&self, name: &PackageName) -> bool {
self.required_members().contains(name)
self.required_members().contains_key(name)
}
/// Returns the set of all workspace member dependency groups.
@ -441,18 +470,19 @@ impl Workspace {
return None;
}
let value = self.required_members.get(name);
let is_required_member = value.is_some();
let editability = value.copied().flatten();
Some(Requirement {
name: member.pyproject_toml.project.as_ref()?.name.clone(),
extras: Box::new([]),
groups: groups.into_boxed_slice(),
marker: MarkerTree::TRUE,
source: if member
.pyproject_toml()
.is_package(!self.is_required_member(name))
{
source: if member.pyproject_toml().is_package(!is_required_member) {
RequirementSource::Directory {
install_path: member.root.clone().into_boxed_path(),
editable: Some(true),
editable: Some(editability.unwrap_or(true)),
r#virtual: Some(false),
url,
}
@ -831,7 +861,7 @@ impl Workspace {
&workspace_members,
&workspace_sources,
&workspace_pyproject_toml,
);
)?;
Ok(Self {
install_path: workspace_root,
@ -1250,14 +1280,17 @@ impl ProjectWorkspace {
/// Set the `pyproject.toml` for the current project.
///
/// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
#[must_use]
pub fn with_pyproject_toml(self, pyproject_toml: PyProjectToml) -> Option<Self> {
Some(Self {
workspace: self
.workspace
.with_pyproject_toml(&self.project_name, pyproject_toml)?,
..self
})
pub fn with_pyproject_toml(
self,
pyproject_toml: PyProjectToml,
) -> Result<Option<Self>, WorkspaceError> {
let Some(workspace) = self
.workspace
.with_pyproject_toml(&self.project_name, pyproject_toml)?
else {
return Ok(None);
};
Ok(Some(Self { workspace, ..self }))
}
/// Find the workspace for a project.
@ -1325,7 +1358,7 @@ impl ProjectWorkspace {
&current_project_as_members,
&workspace_sources,
project_pyproject_toml,
);
)?;
return Ok(Self {
project_root: project_path.clone(),
@ -1647,11 +1680,16 @@ impl VirtualProject {
/// Set the `pyproject.toml` for the current project.
///
/// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
#[must_use]
pub fn with_pyproject_toml(self, pyproject_toml: PyProjectToml) -> Option<Self> {
match self {
pub fn with_pyproject_toml(
self,
pyproject_toml: PyProjectToml,
) -> Result<Option<Self>, WorkspaceError> {
Ok(match self {
Self::Project(project) => {
Some(Self::Project(project.with_pyproject_toml(pyproject_toml)?))
let Some(project) = project.with_pyproject_toml(pyproject_toml)? else {
return Ok(None);
};
Some(Self::Project(project))
}
Self::NonProject(workspace) => {
// If this is a non-project workspace root, then by definition the root isn't a
@ -1661,7 +1699,7 @@ impl VirtualProject {
..workspace.clone()
}))
}
}
})
}
/// Return the root of the project.
@ -1788,7 +1826,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -1842,7 +1880,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -1923,14 +1961,15 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [
"bird-feeder",
"seeds"
],
"required_members": {
"bird-feeder": null,
"seeds": null
},
"sources": {
"bird-feeder": [
{
"workspace": true,
"editable": null,
"extra": null,
"group": null
}
@ -1954,6 +1993,7 @@ mod tests {
"bird-feeder": [
{
"workspace": true,
"editable": null,
"extra": null,
"group": null
}
@ -2048,10 +2088,10 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [
"bird-feeder",
"seeds"
],
"required_members": {
"bird-feeder": null,
"seeds": null
},
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2119,7 +2159,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2254,7 +2294,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2362,7 +2402,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2484,7 +2524,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2580,7 +2620,7 @@ mod tests {
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": [],
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
@ -2771,7 +2811,7 @@ bar = ["b"]
insta::with_settings!({filters => filters}, {
assert_snapshot!(
error,
@"Two workspace members are both named: `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
@"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
});
Ok(())

View file

@ -1391,7 +1391,7 @@ impl AddTarget {
let project = project
.with_pyproject_toml(
toml::from_str(content).map_err(ProjectError::PyprojectTomlParse)?,
)
)?
.ok_or(ProjectError::PyprojectTomlUpdate)?;
Ok(Self::Project(project, venv))
}

View file

@ -37,7 +37,7 @@ use uv_scripts::Pep723Script;
use uv_settings::PythonInstallMirrors;
use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceMember};
use uv_workspace::{DiscoveryOptions, Editability, Workspace, WorkspaceCache, WorkspaceMember};
use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger};
use crate::commands::project::lock_target::LockTarget;
@ -943,7 +943,7 @@ impl ValidatedLock {
install_path: &Path,
packages: &BTreeMap<PackageName, WorkspaceMember>,
members: &[PackageName],
required_members: &BTreeSet<PackageName>,
required_members: &BTreeMap<PackageName, Editability>,
requirements: &[Requirement],
dependency_groups: &BTreeMap<GroupName, Vec<Requirement>>,
constraints: &[Requirement],
@ -1187,6 +1187,18 @@ impl ValidatedLock {
);
Ok(Self::Preferable(lock))
}
SatisfiesResult::MismatchedEditable(name, expected) => {
if expected {
debug!(
"Resolving despite existing lockfile due to mismatched source: `{name}` (expected: `editable`)"
);
} else {
debug!(
"Resolving despite existing lockfile due to mismatched source: `{name}` (unexpected: `editable`)"
);
}
Ok(Self::Preferable(lock))
}
SatisfiesResult::MismatchedVirtual(name, expected) => {
if expected {
debug!(
@ -1194,7 +1206,7 @@ impl ValidatedLock {
);
} else {
debug!(
"Resolving despite existing lockfile due to mismatched source: `{name}` (expected: `editable`)"
"Resolving despite existing lockfile due to mismatched source: `{name}` (unexpected: `virtual`)"
);
}
Ok(Self::Preferable(lock))

View file

@ -1,4 +1,4 @@
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use itertools::Either;
@ -12,7 +12,7 @@ use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl};
use uv_resolver::{Lock, LockVersion, VERSION};
use uv_scripts::Pep723Script;
use uv_workspace::dependency_groups::{DependencyGroupError, FlatDependencyGroup};
use uv_workspace::{Workspace, WorkspaceMember};
use uv_workspace::{Editability, Workspace, WorkspaceMember};
use crate::commands::project::{ProjectError, find_requires_python};
@ -156,11 +156,11 @@ 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> {
pub(crate) fn required_members(self) -> &'lock BTreeMap<PackageName, Editability> {
match self {
Self::Workspace(workspace) => workspace.required_members(),
Self::Script(_) => {
static EMPTY: BTreeSet<PackageName> = BTreeSet::new();
static EMPTY: BTreeMap<PackageName, Editability> = BTreeMap::new();
&EMPTY
}
}

View file

@ -251,6 +251,9 @@ pub(crate) enum ProjectError {
#[error(transparent)]
Lowering(#[from] uv_distribution::LoweringError),
#[error(transparent)]
Workspace(#[from] uv_workspace::WorkspaceError),
#[error(transparent)]
PyprojectMut(#[from] uv_workspace::pyproject_mut::Error),

View file

@ -436,7 +436,7 @@ impl RemoveTarget {
let project = project
.with_pyproject_toml(
toml::from_str(content).map_err(ProjectError::PyprojectTomlParse)?,
)
)?
.ok_or(ProjectError::PyprojectTomlUpdate)?;
Ok(Self::Project(project))
}

View file

@ -392,7 +392,7 @@ fn update_project(
// Update the `pyproject.toml` in-memory.
let project = project
.with_pyproject_toml(toml::from_str(&content).map_err(ProjectError::PyprojectTomlParse)?)
.with_pyproject_toml(toml::from_str(&content).map_err(ProjectError::PyprojectTomlParse)?)?
.ok_or(ProjectError::PyprojectTomlUpdate)?;
Ok(project)

View file

@ -2248,26 +2248,13 @@ fn add_workspace_editable() -> Result<()> {
let child1 = context.temp_dir.join("child1");
// `--no-editable` should error.
// `--no-editable` should add `editable = false`.
let mut add_cmd = context.add();
add_cmd
.arg("child2")
.arg("--no-editable")
.current_dir(&child1);
uv_snapshot!(context.filters(), add_cmd, @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Workspace dependency `child2` was marked as `--no-editable`, but workspace dependencies are always added in editable mode. Pass `--no-editable` to `uv sync` or `uv run` to install workspace dependencies in non-editable mode.
"###);
// `--editable` should not.
let mut add_cmd = context.add();
add_cmd.arg("child2").arg("--editable").current_dir(&child1);
uv_snapshot!(context.filters(), add_cmd, @r"
success: true
exit_code: 0
@ -2301,7 +2288,50 @@ fn add_workspace_editable() -> Result<()> {
build-backend = "hatchling.build"
[tool.uv.sources]
child2 = { workspace = true }
child2 = { workspace = true, editable = false }
"#
);
});
// `--editable` should not.
let mut add_cmd = context.add();
add_cmd.arg("child2").arg("--editable").current_dir(&child1);
uv_snapshot!(context.filters(), add_cmd, @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Prepared 2 packages in [TIME]
Uninstalled 2 packages in [TIME]
Installed 2 packages in [TIME]
~ child1==0.1.0 (from file://[TEMP_DIR]/child1)
~ child2==0.1.0 (from file://[TEMP_DIR]/child2)
");
let pyproject_toml = fs_err::read_to_string(child1.join("pyproject.toml"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r#"
[project]
name = "child1"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"child2",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv.sources]
child2 = { workspace = true, editable = true }
"#
);
});

View file

@ -13599,3 +13599,478 @@ fn sync_extra_build_dependencies_cache() -> Result<()> {
Ok(())
}
#[test]
#[cfg(not(windows))]
fn toggle_workspace_editable() -> Result<()> {
let context = TestContext::new("3.12");
let child = context.temp_dir.child("child");
let pyproject_toml = child.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig>=1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
child
.child("src")
.child("child")
.child("__init__.py")
.touch()?;
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 = ["child"]
[tool.uv.workspace]
members = ["child"]
[tool.uv.sources]
child = { workspace = true }
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ child==0.1.0 (from file://[TEMP_DIR]/child)
+ iniconfig==2.0.0
");
let lock = context.read("uv.lock");
// The child should be editable by default.
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
members = [
"child",
"project",
]
[[package]]
name = "child"
version = "0.1.0"
source = { editable = "child" }
dependencies = [
{ name = "iniconfig" },
]
[package.metadata]
requires-dist = [{ name = "iniconfig", specifier = ">=1" }]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "child" },
]
[package.metadata]
requires-dist = [{ name = "child", editable = "child" }]
"#
);
});
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 = ["child"]
[tool.uv.workspace]
members = ["child"]
[tool.uv.sources]
child = { workspace = true, editable = false }
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Prepared 1 package in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
~ child==0.1.0 (from file://[TEMP_DIR]/child)
");
let lock = context.read("uv.lock");
// The child should be editable by default.
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
members = [
"child",
"project",
]
[[package]]
name = "child"
version = "0.1.0"
source = { directory = "child" }
dependencies = [
{ name = "iniconfig" },
]
[package.metadata]
requires-dist = [{ name = "iniconfig", specifier = ">=1" }]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "child" },
]
[package.metadata]
requires-dist = [{ name = "child", directory = "child" }]
"#
);
});
Ok(())
}
#[test]
#[cfg(not(windows))]
fn workspace_editable_conflict() -> Result<()> {
let context = TestContext::new("3.12");
let child1 = context.temp_dir.child("child1");
let pyproject_toml = child1.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "child1"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig>=1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
child1
.child("src")
.child("child1")
.child("__init__.py")
.touch()?;
let child2 = context.temp_dir.child("child2");
let pyproject_toml = child2.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "child2"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child1"]
[tool.uv.sources]
child1 = { workspace = true }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
child2
.child("src")
.child("child2")
.child("__init__.py")
.touch()?;
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 = ["child1"]
[tool.uv.workspace]
members = ["child1", "child2"]
[tool.uv.sources]
child1 = { workspace = true, editable = true }
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ child1==0.1.0 (from file://[TEMP_DIR]/child1)
+ iniconfig==2.0.0
");
let lock = context.read("uv.lock");
// If one member declares `editable = true`, and the other omits `editable`, use editable.
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
members = [
"child1",
"child2",
"project",
]
[[package]]
name = "child1"
version = "0.1.0"
source = { editable = "child1" }
dependencies = [
{ name = "iniconfig" },
]
[package.metadata]
requires-dist = [{ name = "iniconfig", specifier = ">=1" }]
[[package]]
name = "child2"
version = "0.1.0"
source = { editable = "child2" }
dependencies = [
{ name = "child1" },
]
[package.metadata]
requires-dist = [{ name = "child1", editable = "child1" }]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "child1" },
]
[package.metadata]
requires-dist = [{ name = "child1", editable = "child1" }]
"#
);
});
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 = ["child1"]
[tool.uv.workspace]
members = ["child1", "child2"]
[tool.uv.sources]
child1 = { workspace = true, editable = false }
"#,
)?;
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
Prepared 1 package in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
~ child1==0.1.0 (from file://[TEMP_DIR]/child1)
");
let lock = context.read("uv.lock");
// If one member declares `editable = false`, and the other omits `editable`, use non-editable.
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
members = [
"child1",
"child2",
"project",
]
[[package]]
name = "child1"
version = "0.1.0"
source = { directory = "child1" }
dependencies = [
{ name = "iniconfig" },
]
[package.metadata]
requires-dist = [{ name = "iniconfig", specifier = ">=1" }]
[[package]]
name = "child2"
version = "0.1.0"
source = { editable = "child2" }
dependencies = [
{ name = "child1" },
]
[package.metadata]
requires-dist = [{ name = "child1", directory = "child1" }]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "child1" },
]
[package.metadata]
requires-dist = [{ name = "child1", directory = "child1" }]
"#
);
});
let child2 = context.temp_dir.child("child2");
let pyproject_toml = child2.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "child2"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child1"]
[tool.uv.sources]
child1 = { workspace = true, editable = true }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
// If the `editable` declarations are conflicting, raise an error.
uv_snapshot!(context.filters(), context.sync(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Workspace member `child1` was requested as both `editable = true` and `editable = false`
");
Ok(())
}

7
uv.schema.json generated
View file

@ -2133,6 +2133,13 @@
"description": "A dependency on another package in the workspace.",
"type": "object",
"properties": {
"editable": {
"description": "Whether the package should be installed as editable. Defaults to `true`.",
"type": [
"boolean",
"null"
]
},
"extra": {
"anyOf": [
{