From e84c9231aa743fe2be3947f24a6a908a94cc1cb2 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 7 Sep 2025 11:41:17 -0400 Subject: [PATCH] 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. --- .../uv-distribution/src/metadata/lowering.rs | 32 +- crates/uv-resolver/src/lock/mod.rs | 48 +- crates/uv-workspace/src/lib.rs | 4 +- crates/uv-workspace/src/pyproject.rs | 22 +- crates/uv-workspace/src/workspace.rs | 162 +++--- crates/uv/src/commands/project/add.rs | 2 +- crates/uv/src/commands/project/lock.rs | 18 +- crates/uv/src/commands/project/lock_target.rs | 8 +- crates/uv/src/commands/project/mod.rs | 3 + crates/uv/src/commands/project/remove.rs | 2 +- crates/uv/src/commands/project/version.rs | 2 +- crates/uv/tests/it/edit.rs | 60 ++- crates/uv/tests/it/sync.rs | 475 ++++++++++++++++++ uv.schema.json | 7 + 14 files changed, 713 insertions(+), 132 deletions(-) diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 36b6ff3da..666175b0d 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -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) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 2516fccd9..b9688a831 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -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, members: &[PackageName], - required_members: &BTreeSet, + required_members: &BTreeMap, 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, &'lock BTreeSet), /// 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. diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index 0e1b3974c..c15a8b007 100644 --- a/crates/uv-workspace/src/lib.rs +++ b/crates/uv-workspace/src/lib.rs @@ -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; diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index e73cc764b..cd643d5fe 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -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, #[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, diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index d277dc7c8..1ed66afae 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -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), VersionSpecifiers>; +pub type Editability = Option; + /// 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, + /// The workspace members that are required by other members, and whether they were requested + /// as editable. + required_members: BTreeMap, /// 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 { + ) -> Result, 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 { + pub fn required_members(&self) -> &BTreeMap { &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, sources: &BTreeMap, pyproject_toml: &PyProjectToml, - ) -> BTreeSet { - sources + ) -> Result, 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 { - Some(Self { - workspace: self - .workspace - .with_pyproject_toml(&self.project_name, pyproject_toml)?, - ..self - }) + pub fn with_pyproject_toml( + self, + pyproject_toml: PyProjectToml, + ) -> Result, 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 { ¤t_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 { - match self { + pub fn with_pyproject_toml( + self, + pyproject_toml: PyProjectToml, + ) -> Result, 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(()) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c3443817b..ac9fce21e 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -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)) } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index df9050cf4..9ced95d6e 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -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, members: &[PackageName], - required_members: &BTreeSet, + required_members: &BTreeMap, requirements: &[Requirement], dependency_groups: &BTreeMap>, 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)) diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index 55a726bf4..9bd1a8222 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -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 { + pub(crate) fn required_members(self) -> &'lock BTreeMap { match self { Self::Workspace(workspace) => workspace.required_members(), Self::Script(_) => { - static EMPTY: BTreeSet = BTreeSet::new(); + static EMPTY: BTreeMap = BTreeMap::new(); &EMPTY } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 4c6c3b3ba..8f2a4a767 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -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), diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index a7d0ba183..bcc24ee1c 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -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)) } diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index c2426a1cc..4c60ecae7 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -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) diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index f627a5961..10a5385d8 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -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 } "# ); }); diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index f7113e419..906cb4a86 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -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(()) +} diff --git a/uv.schema.json b/uv.schema.json index c1c158121..2de1f1a46 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -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": [ {