mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-01 12:24:15 +00:00
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:
parent
97777cda66
commit
e84c9231aa
14 changed files with 713 additions and 132 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
¤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<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(())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue