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

@ -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(())