Avoid installing tool workspace member dependencies as editable (#18891)

## Summary

Right now, if you use `uv tool install`, we install the tool itself as
non-editable, but if the tool is part of a workspace, then any workspace
dependencies are (accidentally) installed as editable. This PR modifies
the behavior such that those dependencies are installed as non-editable,
unless `--editable` is provided, in which case the tool itself and any
workspace dependencies respect `--editable`.

Similar logic applies to `--with` and `--with-editable`. If the target
is in a workspace, we propagate the no-editable and yes-editable flags
(respectively) to its members.

Closes https://github.com/astral-sh/uv/issues/16306
This commit is contained in:
Charlie Marsh
2026-04-14 10:01:31 -04:00
committed by GitHub
parent e0793d517d
commit 92222f0192
27 changed files with 1392 additions and 37 deletions
+4 -1
View File
@@ -135,7 +135,9 @@ mod resolver {
ExcludeNewer, FlatIndex, InMemoryIndex, Manifest, OptionsBuilder, PythonRequirement,
Resolver, ResolverEnvironment, ResolverOutput,
};
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_types::{
BuildIsolation, EmptyInstalledPackages, HashStrategy, SourceTreeEditablePolicy,
};
use uv_workspace::WorkspaceCache;
static MARKERS: LazyLock<MarkerEnvironment> = LazyLock::new(|| {
@@ -238,6 +240,7 @@ mod resolver {
&hashes,
exclude_newer,
sources,
SourceTreeEditablePolicy::Project,
workspace_cache,
concurrency.clone(),
Preview::default(),
+4
View File
@@ -650,6 +650,7 @@ impl SourceBuild {
install_path,
locations,
no_sources,
true,
workspace_cache,
credentials_cache,
)
@@ -1095,6 +1096,9 @@ async fn create_pep517_build_environment(
install_path,
locations,
&no_sources,
build_context
.source_tree_editable_policy()
.workspace_member_editable(None),
workspace_cache,
credentials_cache,
)
+8 -1
View File
@@ -36,7 +36,7 @@ use uv_resolver::{
};
use uv_types::{
AnyErrorBuild, BuildArena, BuildContext, BuildIsolation, BuildStack, EmptyInstalledPackages,
HashStrategy, InFlight,
HashStrategy, InFlight, SourceTreeEditablePolicy,
};
use uv_workspace::WorkspaceCache;
@@ -98,6 +98,7 @@ pub struct BuildDispatch<'a> {
source_build_context: SourceBuildContext,
build_extra_env_vars: FxHashMap<OsString, OsString>,
sources: NoSources,
source_tree_editable_policy: SourceTreeEditablePolicy,
workspace_cache: WorkspaceCache,
concurrency: Concurrency,
preview: Preview,
@@ -124,6 +125,7 @@ impl<'a> BuildDispatch<'a> {
hasher: &'a HashStrategy,
exclude_newer: ExcludeNewer,
sources: NoSources,
source_tree_editable_policy: SourceTreeEditablePolicy,
workspace_cache: WorkspaceCache,
concurrency: Concurrency,
preview: Preview,
@@ -150,6 +152,7 @@ impl<'a> BuildDispatch<'a> {
source_build_context: SourceBuildContext::new(concurrency.builds_semaphore.clone()),
build_extra_env_vars: FxHashMap::default(),
sources,
source_tree_editable_policy,
workspace_cache,
concurrency,
preview,
@@ -220,6 +223,10 @@ impl BuildContext for BuildDispatch<'_> {
&self.sources
}
fn source_tree_editable_policy(&self) -> SourceTreeEditablePolicy {
self.source_tree_editable_policy
}
fn locations(&self) -> &IndexLocations {
self.index_locations
}
@@ -42,6 +42,7 @@ impl BuildRequires {
install_path: &Path,
locations: &IndexLocations,
sources: &NoSources,
editable: bool,
cache: &WorkspaceCache,
credentials_cache: &CredentialsCache,
) -> Result<Self, MetadataError> {
@@ -64,6 +65,7 @@ impl BuildRequires {
&project_workspace,
locations,
sources,
editable,
credentials_cache,
)
}
@@ -74,6 +76,7 @@ impl BuildRequires {
project_workspace: &ProjectWorkspace,
locations: &IndexLocations,
sources: &NoSources,
editable: bool,
credentials_cache: &CredentialsCache,
) -> Result<Self, MetadataError> {
// Collect any `tool.uv.index` entries.
@@ -132,6 +135,7 @@ impl BuildRequires {
locations,
project_workspace.workspace(),
None,
editable,
credentials_cache,
)
.map(move |requirement| match requirement {
@@ -206,6 +210,7 @@ impl BuildRequires {
locations,
workspace,
None,
true,
credentials_cache,
)
.map(move |requirement| match requirement {
@@ -294,6 +299,7 @@ impl LoweredExtraBuildDependencies {
index_locations,
workspace,
None,
true,
credentials_cache,
)
.map(move |requirement| {
@@ -164,6 +164,7 @@ impl SourcedDependencyGroups {
locations,
project.workspace(),
git_member,
true,
credentials_cache,
)
.map(move |requirement| match requirement {
@@ -46,6 +46,7 @@ impl LoweredRequirement {
locations: &'data IndexLocations,
workspace: &'data Workspace,
git_member: Option<&'data GitWorkspaceMember<'data>>,
editable: bool,
credentials_cache: &'data CredentialsCache,
) -> impl Iterator<Item = Result<Self, LoweringError>> + use<'data> + 'data {
// Identify the source from the `tool.uv.sources` table.
@@ -320,7 +321,7 @@ impl LoweredRequirement {
RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable: Some(editability.unwrap_or(true)),
editable: Some(editability.unwrap_or(editable)),
r#virtual: Some(false),
}
} else {
@@ -91,6 +91,7 @@ impl Metadata {
git_source: Option<&GitWorkspaceMember<'_>>,
locations: &IndexLocations,
sources: NoSources,
editable: bool,
cache: &WorkspaceCache,
credentials_cache: &CredentialsCache,
) -> Result<Self, MetadataError> {
@@ -113,6 +114,7 @@ impl Metadata {
git_source,
locations,
sources,
editable,
cache,
credentials_cache,
)
@@ -48,6 +48,7 @@ impl RequiresDist {
git_member: Option<&GitWorkspaceMember<'_>>,
locations: &IndexLocations,
sources: NoSources,
editable: bool,
cache: &WorkspaceCache,
credentials_cache: &CredentialsCache,
) -> Result<Self, MetadataError> {
@@ -78,6 +79,7 @@ impl RequiresDist {
git_member,
locations,
&sources,
editable,
credentials_cache,
)
}
@@ -88,6 +90,7 @@ impl RequiresDist {
git_member: Option<&GitWorkspaceMember<'_>>,
locations: &IndexLocations,
no_sources: &NoSources,
editable: bool,
credentials_cache: &CredentialsCache,
) -> Result<Self, MetadataError> {
// Collect any `tool.uv.index` entries.
@@ -149,6 +152,7 @@ impl RequiresDist {
locations,
project_workspace.workspace(),
git_member,
editable,
credentials_cache,
)
.map(move |requirement| match requirement {
@@ -191,6 +195,7 @@ impl RequiresDist {
locations,
project_workspace.workspace(),
git_member,
editable,
credentials_cache,
)
.map(move |requirement| match requirement {
@@ -477,6 +482,7 @@ mod test {
None,
&IndexLocations::default(),
&NoSources::default(),
true,
&CredentialsCache::new(),
)?)
}
+27
View File
@@ -1281,6 +1281,14 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
return Err(Error::HashesNotSupportedSourceTree(source.to_string()));
}
// Project-style resolution always lowers workspace members as editable. Tool-style
// resolution preserves an explicit local requirement choice instead, defaulting implicit
// workspace siblings to non-editable.
let editable = self
.build_context
.source_tree_editable_policy()
.workspace_member_editable(resource.editable);
// If the metadata is static, return it.
let dynamic = match StaticMetadata::read(source, resource.install_path, None).await? {
StaticMetadata::Some(metadata) => {
@@ -1291,6 +1299,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
None,
self.build_context.locations(),
self.build_context.sources().clone(),
editable,
self.build_context.workspace_cache(),
credentials_cache,
)
@@ -1345,6 +1354,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
None,
self.build_context.locations(),
self.build_context.sources().clone(),
editable,
self.build_context.workspace_cache(),
credentials_cache,
)
@@ -1395,6 +1405,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
None,
self.build_context.locations(),
self.build_context.sources().clone(),
editable,
self.build_context.workspace_cache(),
credentials_cache,
)
@@ -1457,6 +1468,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
None,
self.build_context.locations(),
self.build_context.sources().clone(),
editable,
self.build_context.workspace_cache(),
credentials_cache,
)
@@ -1533,6 +1545,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
None,
self.build_context.locations(),
self.build_context.sources().clone(),
self.build_context
.source_tree_editable_policy()
.workspace_member_editable(None),
self.build_context.workspace_cache(),
credentials_cache,
)
@@ -1855,6 +1870,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
Some(&git_member),
self.build_context.locations(),
self.build_context.sources().clone(),
self.build_context
.source_tree_editable_policy()
.workspace_member_editable(None),
self.build_context.workspace_cache(),
credentials_cache,
)
@@ -1889,6 +1907,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
Some(&git_member),
self.build_context.locations(),
self.build_context.sources().clone(),
self.build_context
.source_tree_editable_policy()
.workspace_member_editable(None),
self.build_context.workspace_cache(),
credentials_cache,
)
@@ -1942,6 +1963,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
Some(&git_member),
self.build_context.locations(),
self.build_context.sources().clone(),
self.build_context
.source_tree_editable_policy()
.workspace_member_editable(None),
self.build_context.workspace_cache(),
credentials_cache,
)
@@ -2004,6 +2028,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
Some(&git_member),
self.build_context.locations(),
self.build_context.sources().clone(),
self.build_context
.source_tree_editable_policy()
.workspace_member_editable(None),
self.build_context.workspace_cache(),
credentials_cache,
)
+8
View File
@@ -16,6 +16,9 @@ use uv_settings::{ToolOptions, ToolOptionsWire};
#[serde(try_from = "ToolWire", into = "ToolWire")]
pub struct Tool {
/// The requirements requested by the user during installation.
///
/// The first requirement is the tool target itself; any remaining requirements come from
/// `--with`.
requirements: Vec<Requirement>,
/// The constraints requested by the user during installation.
constraints: Vec<Requirement>,
@@ -356,6 +359,11 @@ impl Tool {
&self.requirements
}
/// Return the primary requirement for the tool itself.
pub fn target_requirement(&self) -> Option<&Requirement> {
self.requirements.first()
}
pub fn constraints(&self) -> &[Requirement] {
&self.constraints
}
+35
View File
@@ -21,6 +21,36 @@ use uv_workspace::WorkspaceCache;
use crate::{BuildArena, BuildIsolation};
/// Controls how source tree requirements influence workspace-member editability during lowering.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
pub enum SourceTreeEditablePolicy {
/// Use project-style semantics when lowering workspace members.
///
/// Explicit source-tree editable settings are ignored, preserving the existing implicit
/// editable default for workspace members.
#[default]
Project,
/// Use tool-style semantics when lowering workspace members.
///
/// Explicit source-tree editable settings are preserved, while implicit workspace members
/// default to non-editable.
Tool,
}
impl SourceTreeEditablePolicy {
/// Return the default editable mode for workspace members lowered under this policy.
///
/// `explicit` is the explicit editable choice on the source tree being lowered, if any. In
/// `Tool` mode it propagates to workspace siblings; in `Project` mode it is ignored.
pub fn workspace_member_editable(self, explicit: Option<bool>) -> bool {
match self {
Self::Project => true,
Self::Tool => explicit.unwrap_or(false),
}
}
}
/// Avoids cyclic crate dependencies between resolver, installer and builder.
///
/// To resolve the dependencies of a packages, we may need to build one or more source
@@ -99,6 +129,11 @@ pub trait BuildContext {
/// Whether to incorporate `tool.uv.sources` when resolving requirements.
fn sources(&self) -> &NoSources;
/// How source tree requirements should influence workspace-member editability.
fn source_tree_editable_policy(&self) -> SourceTreeEditablePolicy {
SourceTreeEditablePolicy::Project
}
/// The index locations being searched.
fn locations(&self) -> &IndexLocations;
+2 -1
View File
@@ -39,7 +39,7 @@ use uv_python::{
use uv_requirements::RequirementsSource;
use uv_resolver::{ExcludeNewer, FlatIndex};
use uv_settings::PythonInstallMirrors;
use uv_types::{AnyErrorBuild, BuildContext, BuildStack, HashStrategy};
use uv_types::{AnyErrorBuild, BuildContext, BuildStack, HashStrategy, SourceTreeEditablePolicy};
use uv_workspace::pyproject::ExtraBuildDependencies;
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError};
@@ -642,6 +642,7 @@ async fn build_package(
&hasher,
exclude_newer,
sources.clone(),
SourceTreeEditablePolicy::Project,
workspace_cache.clone(),
concurrency.clone(),
preview,
+2 -1
View File
@@ -48,7 +48,7 @@ use uv_resolver::{
use uv_settings::PythonInstallMirrors;
use uv_static::EnvVars;
use uv_torch::{TorchMode, TorchSource, TorchStrategy};
use uv_types::{EmptyInstalledPackages, HashStrategy};
use uv_types::{EmptyInstalledPackages, HashStrategy, SourceTreeEditablePolicy};
use uv_warnings::warn_user;
use uv_workspace::WorkspaceCache;
use uv_workspace::pyproject::ExtraBuildDependencies;
@@ -546,6 +546,7 @@ pub(crate) async fn pip_compile(
&build_hashes,
exclude_newer.clone(),
sources,
SourceTreeEditablePolicy::Project,
workspace_cache,
concurrency.clone(),
preview,
+3 -1
View File
@@ -36,7 +36,7 @@ use uv_resolver::{
};
use uv_settings::PythonInstallMirrors;
use uv_torch::{TorchMode, TorchSource, TorchStrategy};
use uv_types::HashStrategy;
use uv_types::{HashStrategy, SourceTreeEditablePolicy};
use uv_warnings::warn_user;
use uv_workspace::WorkspaceCache;
use uv_workspace::pyproject::ExtraBuildDependencies;
@@ -486,6 +486,7 @@ pub(crate) async fn pip_install(
&build_hasher,
exclude_newer.clone(),
sources.clone(),
SourceTreeEditablePolicy::Project,
workspace_cache.clone(),
concurrency.clone(),
preview,
@@ -642,6 +643,7 @@ pub(crate) async fn pip_install(
&build_hasher,
exclude_newer.clone(),
sources,
SourceTreeEditablePolicy::Project,
workspace_cache,
concurrency.clone(),
preview,
+3 -1
View File
@@ -35,7 +35,7 @@ use uv_resolver::{
};
use uv_settings::PythonInstallMirrors;
use uv_torch::{TorchMode, TorchSource, TorchStrategy};
use uv_types::HashStrategy;
use uv_types::{HashStrategy, SourceTreeEditablePolicy};
use uv_warnings::warn_user;
use uv_workspace::WorkspaceCache;
use uv_workspace::pyproject::ExtraBuildDependencies;
@@ -393,6 +393,7 @@ pub(crate) async fn pip_sync(
&build_hasher,
exclude_newer.clone(),
sources.clone(),
SourceTreeEditablePolicy::Project,
workspace_cache.clone(),
concurrency.clone(),
preview,
@@ -533,6 +534,7 @@ pub(crate) async fn pip_sync(
&build_hasher,
exclude_newer.clone(),
sources,
SourceTreeEditablePolicy::Project,
workspace_cache,
concurrency.clone(),
preview,
+2 -1
View File
@@ -36,7 +36,7 @@ use uv_requirements::{NamedRequirementsResolver, RequirementsSource, Requirement
use uv_resolver::FlatIndex;
use uv_scripts::{Pep723Metadata, Pep723Script};
use uv_settings::PythonInstallMirrors;
use uv_types::{BuildIsolation, HashStrategy};
use uv_types::{BuildIsolation, HashStrategy, SourceTreeEditablePolicy};
use uv_warnings::warn_user_once;
use uv_workspace::pyproject::{DependencyType, Source, SourceError, Sources, ToolUvSources};
use uv_workspace::pyproject_mut::{AddBoundsKind, ArrayEdit, DependencyTarget, PyProjectTomlMut};
@@ -454,6 +454,7 @@ pub(crate) async fn add(
&build_hasher,
settings.resolver.exclude_newer.clone(),
sources,
SourceTreeEditablePolicy::Project,
// No workspace caching since `uv add` changes the workspace definition.
WorkspaceCache::default(),
concurrency.clone(),
@@ -21,6 +21,7 @@ use uv_distribution_types::{
use uv_fs::PythonExt;
use uv_preview::Preview;
use uv_python::{Interpreter, PythonEnvironment, canonicalize_executable};
use uv_types::SourceTreeEditablePolicy;
use uv_workspace::WorkspaceCache;
/// An ephemeral [`PythonEnvironment`] for running an individual command.
@@ -141,6 +142,7 @@ impl CachedEnvironment {
spec,
&interpreter,
python_platform,
SourceTreeEditablePolicy::Project,
build_constraints.clone(),
&settings.resolver,
client_builder,
+4 -1
View File
@@ -36,7 +36,9 @@ use uv_resolver::{
};
use uv_scripts::Pep723Script;
use uv_settings::PythonInstallMirrors;
use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_types::{
BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy, SourceTreeEditablePolicy,
};
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::{DiscoveryOptions, Editability, Workspace, WorkspaceCache, WorkspaceMember};
@@ -795,6 +797,7 @@ async fn do_lock(
&build_hasher,
exclude_newer.clone(),
sources.clone(),
SourceTreeEditablePolicy::Project,
workspace_cache.clone(),
concurrency.clone(),
preview,
+7 -2
View File
@@ -44,7 +44,7 @@ use uv_scripts::Pep723ItemRef;
use uv_settings::PythonInstallMirrors;
use uv_static::EnvVars;
use uv_torch::{TorchSource, TorchStrategy};
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, SourceTreeEditablePolicy};
use uv_virtualenv::remove_virtualenv;
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::dependency_groups::DependencyGroupError;
@@ -1928,6 +1928,7 @@ pub(crate) async fn resolve_names(
&build_hasher,
exclude_newer.clone(),
sources.clone(),
SourceTreeEditablePolicy::Project,
workspace_cache.clone(),
concurrency.clone(),
preview,
@@ -1996,6 +1997,7 @@ pub(crate) async fn resolve_environment(
spec: EnvironmentSpecification<'_>,
interpreter: &Interpreter,
python_platform: Option<&TargetTriple>,
source_tree_editable_policy: SourceTreeEditablePolicy,
build_constraints: Constraints,
settings: &ResolverSettings,
client_builder: &BaseClientBuilder<'_>,
@@ -2167,6 +2169,7 @@ pub(crate) async fn resolve_environment(
&build_hasher,
exclude_newer.clone(),
sources.clone(),
source_tree_editable_policy,
workspace_cache.clone(),
concurrency.clone(),
preview,
@@ -2307,6 +2310,7 @@ pub(crate) async fn sync_environment(
&build_hasher,
exclude_newer.clone(),
sources,
SourceTreeEditablePolicy::Project,
workspace_cache,
concurrency.clone(),
preview,
@@ -2366,6 +2370,7 @@ pub(crate) async fn update_environment(
spec: RequirementsSpecification,
modifications: Modifications,
python_platform: Option<&TargetTriple>,
source_tree_editable_policy: SourceTreeEditablePolicy,
build_constraints: Constraints,
extra_build_requires: ExtraBuildRequires,
settings: &ResolverInstallerSettings,
@@ -2563,6 +2568,7 @@ pub(crate) async fn update_environment(
&build_hasher,
exclude_newer.clone(),
sources.clone(),
source_tree_editable_policy,
workspace_cache.clone(),
concurrency.clone(),
preview,
@@ -2603,7 +2609,6 @@ pub(crate) async fn update_environment(
Ok((resolution, hasher)) => (Resolution::from(resolution), hasher),
Err(err) => return Err(err.into()),
};
// Sync the environment.
let changelog = pip::operations::install(
&resolution,
+2
View File
@@ -41,6 +41,7 @@ use uv_scripts::{Pep723Error, Pep723Item, Pep723Metadata, Pep723Script};
use uv_settings::{EnvironmentOptions, FilesystemOptions, PythonInstallMirrors};
use uv_shell::runnable::WindowsRunnable;
use uv_static::EnvVars;
use uv_types::SourceTreeEditablePolicy;
use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError};
@@ -456,6 +457,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
spec,
modifications,
python_platform.as_ref(),
SourceTreeEditablePolicy::Project,
build_constraints.unwrap_or_default(),
script_extra_build_requires,
&settings,
+23 -21
View File
@@ -32,7 +32,7 @@ use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequ
use uv_resolver::{FlatIndex, ForkStrategy, Installable, Lock, PrereleaseMode, ResolutionMode};
use uv_scripts::Pep723Script;
use uv_settings::PythonInstallMirrors;
use uv_types::{BuildIsolation, HashStrategy};
use uv_types::{BuildIsolation, HashStrategy, SourceTreeEditablePolicy};
use uv_warnings::warn_user;
use uv_workspace::pyproject::Source;
use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache};
@@ -267,6 +267,7 @@ pub(crate) async fn sync(
spec,
modifications,
python_platform.as_ref(),
SourceTreeEditablePolicy::Project,
build_constraints.unwrap_or_default(),
script_extra_build_requires,
&settings,
@@ -844,6 +845,7 @@ pub(super) async fn do_sync(
&build_hasher,
exclude_newer.clone(),
sources.clone(),
SourceTreeEditablePolicy::Project,
workspace_cache.clone(),
concurrency.clone(),
preview,
@@ -880,25 +882,6 @@ pub(super) async fn do_sync(
Ok(changelog)
}
/// Filter out any virtual workspace members.
fn apply_no_virtual_project(resolution: Resolution) -> Resolution {
resolution.filter(|dist| {
let ResolvedDist::Installable { dist, .. } = dist else {
return true;
};
let Dist::Source(dist) = dist.as_ref() else {
return true;
};
let SourceDist::Directory(dist) = dist else {
return true;
};
!dist.r#virtual.unwrap_or(false)
})
}
/// If necessary, convert any editable requirements to non-editable.
fn apply_editable_mode(resolution: Resolution, editable: Option<EditableMode>) -> Resolution {
match editable {
@@ -933,7 +916,7 @@ fn apply_editable_mode(resolution: Resolution, editable: Option<EditableMode>) -
})
}),
// Filter out any editable distributions.
// If a package is editable, map it to a non-editable distribution.
Some(EditableMode::NonEditable) => resolution.map(|dist| {
let ResolvedDist::Installable { dist, version } = dist else {
return None;
@@ -963,6 +946,25 @@ fn apply_editable_mode(resolution: Resolution, editable: Option<EditableMode>) -
}
}
/// Filter out any virtual workspace members.
fn apply_no_virtual_project(resolution: Resolution) -> Resolution {
resolution.filter(|dist| {
let ResolvedDist::Installable { dist, .. } = dist else {
return true;
};
let Dist::Source(dist) = dist.as_ref() else {
return true;
};
let SourceDist::Directory(dist) = dist else {
return true;
};
!dist.r#virtual.unwrap_or(false)
})
}
/// Extract any credentials that are defined on the workspace dependencies themselves. While we
/// don't store plaintext credentials in the `uv.lock`, we do respect credentials that are defined
/// in the `pyproject.toml`.
+1 -2
View File
@@ -10,8 +10,7 @@ use std::{
use tracing::{debug, warn};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_distribution_types::Requirement;
use uv_distribution_types::{InstalledDist, Name};
use uv_distribution_types::{InstalledDist, Name, Requirement};
use uv_fs::Simplified;
#[cfg(unix)]
use uv_fs::replace_symlink;
+6
View File
@@ -29,6 +29,7 @@ use uv_python::{
use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
use uv_tool::InstalledTools;
use uv_types::SourceTreeEditablePolicy;
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::WorkspaceCache;
@@ -510,6 +511,8 @@ pub(crate) async fn install(
// Check if the installed packages meet the requirements.
let site_packages = SitePackages::from_environment(environment.environment())?;
// TODO(charlie): This fast path only validates the explicit requested
// requirements. It can miss editable-mode drift for implicit workspace members.
if matches!(
site_packages.satisfies_requirements(
requirements.iter(),
@@ -576,6 +579,7 @@ pub(crate) async fn install(
spec,
Modifications::Exact,
python_platform.as_ref(),
SourceTreeEditablePolicy::Tool,
Constraints::from_requirements(build_constraints.iter().cloned()),
ExtraBuildRequires::default(),
&settings,
@@ -620,6 +624,7 @@ pub(crate) async fn install(
spec.clone(),
&interpreter,
python_platform.as_ref(),
SourceTreeEditablePolicy::Tool,
Constraints::from_requirements(build_constraints.iter().cloned()),
&settings.resolver,
&client_builder,
@@ -676,6 +681,7 @@ pub(crate) async fn install(
spec,
&interpreter,
python_platform.as_ref(),
SourceTreeEditablePolicy::Tool,
Constraints::from_requirements(build_constraints.iter().cloned()),
&settings.resolver,
&client_builder,
+3 -1
View File
@@ -21,6 +21,7 @@ use uv_python::{
use uv_requirements::RequirementsSpecification;
use uv_settings::{Combine, PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
use uv_tool::{InstalledTools, Tool};
use uv_types::SourceTreeEditablePolicy;
use uv_warnings::write_error_chain;
use uv_workspace::WorkspaceCache;
@@ -338,7 +339,6 @@ async fn upgrade_tool(
existing_tool_receipt.overrides().to_vec(),
existing_tool_receipt.excludes().to_vec(),
);
// Initialize any shared state.
let state = PlatformState::default();
@@ -352,6 +352,7 @@ async fn upgrade_tool(
spec.into(),
interpreter,
python_platform,
SourceTreeEditablePolicy::Tool,
build_constraints.clone(),
&settings.resolver,
client_builder,
@@ -397,6 +398,7 @@ async fn upgrade_tool(
spec,
Modifications::Exact,
python_platform,
SourceTreeEditablePolicy::Tool,
build_constraints,
ExtraBuildRequires::default(),
&settings,
+4 -1
View File
@@ -28,7 +28,9 @@ use uv_python::{
use uv_resolver::{ExcludeNewer, FlatIndex};
use uv_settings::PythonInstallMirrors;
use uv_shell::{Shell, shlex_posix, shlex_windows};
use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy};
use uv_types::{
AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy, SourceTreeEditablePolicy,
};
use uv_virtualenv::OnExisting;
use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError};
@@ -272,6 +274,7 @@ pub(crate) async fn venv(
&build_hasher,
exclude_newer,
sources,
SourceTreeEditablePolicy::Project,
workspace_cache.clone(),
concurrency,
preview,
+803 -1
View File
@@ -6,7 +6,7 @@ use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::{
assert::PathAssert,
fixture::{FileTouch, FileWriteStr, PathChild},
fixture::{FileTouch, FileWriteStr, PathChild, PathCreateDir},
};
use indoc::indoc;
use insta::assert_snapshot;
@@ -579,6 +579,808 @@ fn tool_install_with_editable() -> Result<()> {
Ok(())
}
#[test]
fn tool_install_workspace_members_do_not_override_explicit_with_requirements() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let with_editable_tool_dir = context.temp_dir.child("tools-with-editable");
let with_editable_bin_dir = context.temp_dir.child("bin-with-editable");
let with_tool_dir = context.temp_dir.child("tools-with");
let with_bin_dir = context.temp_dir.child("bin-with");
let root_pyproject = context.temp_dir.child("pyproject.toml");
root_pyproject.write_str(indoc! {
r#"
[project]
name = "root"
version = "0.1.0"
requires-python = ">=3.12"
[project.scripts]
root_cli = "root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.workspace]
members = ["child"]
"#
})?;
let root_src = context.temp_dir.child("src").child("root");
root_src.create_dir_all()?;
root_src.child("__init__.py").write_str(indoc! {
r"
def main():
import child
print(child.MESSAGE)
"
})?;
let child = context.temp_dir.child("child");
child.create_dir_all()?;
child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let child_src = child.child("src").child("child");
child_src.create_dir_all()?;
child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
let status = context
.tool_install()
.arg("--with-editable")
.arg(child.path())
.arg(context.temp_dir.path())
.env(EnvVars::UV_TOOL_DIR, with_editable_tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, with_editable_bin_dir.as_os_str())
.env(EnvVars::PATH, with_editable_bin_dir.as_os_str())
.status()
.expect("failed to run uv tool install with --with-editable");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, with_editable_bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
child_src
.child("__init__.py")
.write_str("MESSAGE = 'CHANGED'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, with_editable_bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
CHANGED
----- stderr -----
");
child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
let status = context
.tool_install()
.arg("--editable")
.arg("--with")
.arg(child.path())
.arg(context.temp_dir.path())
.env(EnvVars::UV_TOOL_DIR, with_tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, with_bin_dir.as_os_str())
.env(EnvVars::PATH, with_bin_dir.as_os_str())
.status()
.expect("failed to run uv tool install with --with");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, with_bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
child_src
.child("__init__.py")
.write_str("MESSAGE = 'CHANGED'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, with_bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
Ok(())
}
#[test]
fn tool_install_preserves_mixed_workspace_member_editability() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let tool_root = context.temp_dir.child("tool-root");
tool_root.create_dir_all()?;
tool_root.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "tool-root"
version = "0.1.0"
requires-python = ">=3.12"
[project.scripts]
root_cli = "tool_root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let tool_root_src = tool_root.child("src").child("tool_root");
tool_root_src.create_dir_all()?;
tool_root_src.child("__init__.py").write_str(indoc! {
r#"
def main():
import importlib.metadata
import other_child
print(f"{importlib.metadata.version('tool-root')} {other_child.MESSAGE}")
"#
})?;
let other_workspace = context.temp_dir.child("other-workspace");
other_workspace.create_dir_all()?;
other_workspace
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "other-workspace"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["other-child"]
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
other-child = { workspace = true }
[tool.uv.workspace]
members = ["packages/*"]
"#})?;
let other_workspace_src = other_workspace.child("src").child("other_workspace");
other_workspace_src.create_dir_all()?;
other_workspace_src.child("__init__.py").touch()?;
let other_child = other_workspace.child("packages").child("other-child");
other_child.create_dir_all()?;
other_child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "other-child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let other_child_src = other_child.child("src").child("other_child");
other_child_src.create_dir_all()?;
other_child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
let status = context
.tool_install()
.arg("--with-editable")
.arg(other_workspace.path())
.arg(tool_root.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool install with mixed workspace editability");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.0 OK
----- stderr -----
");
other_child_src
.child("__init__.py")
.write_str("MESSAGE = 'CHANGED'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.0 CHANGED
----- stderr -----
");
Ok(())
}
#[test]
fn tool_install_preserves_mixed_workspace_member_non_editability() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let tool_root = context.temp_dir.child("tool-root");
tool_root.create_dir_all()?;
tool_root.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "tool-root"
version = "0.1.0"
requires-python = ">=3.12"
[project.scripts]
root_cli = "tool_root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let tool_root_src = tool_root.child("src").child("tool_root");
tool_root_src.create_dir_all()?;
tool_root_src.child("__init__.py").write_str(indoc! {
r#"
def main():
import importlib.metadata
import other_child
print(f"{importlib.metadata.version('tool-root')} {other_child.MESSAGE}")
"#
})?;
let other_workspace = context.temp_dir.child("other-workspace");
other_workspace.create_dir_all()?;
other_workspace
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "other-workspace"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["other-child"]
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
other-child = { workspace = true }
[tool.uv.workspace]
members = ["packages/*"]
"#})?;
let other_workspace_src = other_workspace.child("src").child("other_workspace");
other_workspace_src.create_dir_all()?;
other_workspace_src.child("__init__.py").touch()?;
let other_child = other_workspace.child("packages").child("other-child");
other_child.create_dir_all()?;
other_child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "other-child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let other_child_src = other_child.child("src").child("other_child");
other_child_src.create_dir_all()?;
other_child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
let status = context
.tool_install()
.arg("--editable")
.arg("--with")
.arg(other_workspace.path())
.arg(tool_root.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool install with mixed workspace editability");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.0 OK
----- stderr -----
");
other_child_src
.child("__init__.py")
.write_str("MESSAGE = 'CHANGED'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.0 OK
----- stderr -----
");
Ok(())
}
#[test]
fn tool_install_reinstall_converts_workspace_members_to_non_editable() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let root_pyproject = context.temp_dir.child("pyproject.toml");
root_pyproject.write_str(indoc! {
r#"
[project]
name = "root"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child"]
[project.scripts]
root_cli = "root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
child = { workspace = true }
[tool.uv.workspace]
members = ["child"]
"#
})?;
let root_src = context.temp_dir.child("src").child("root");
root_src.create_dir_all()?;
root_src.child("__init__.py").write_str(indoc! {
r"
def main():
import child
print(child.MESSAGE)
"
})?;
let child = context.temp_dir.child("child");
child.create_dir_all()?;
child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let child_src = child.child("src").child("child");
child_src.create_dir_all()?;
child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
uv_snapshot!(context.filters(), context.tool_install()
.arg("--editable")
.arg(context.temp_dir.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ child==0.1.0 (from file://[TEMP_DIR]/child)
+ root==0.1.0 (from file://[TEMP_DIR]/)
Installed 1 executable: root_cli
");
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
let status = context
.tool_install()
.arg("--reinstall")
.arg(context.temp_dir.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool install --reinstall");
assert!(status.success());
child_src
.child("__init__.py")
.write_str("MESSAGE = 'CHANGED'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
Ok(())
}
#[test]
fn tool_install_workspace_members_are_non_editable_by_default() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let root_pyproject = context.temp_dir.child("pyproject.toml");
root_pyproject.write_str(indoc! {
r#"
[project]
name = "root"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child"]
[project.scripts]
root_cli = "root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
child = { workspace = true }
[tool.uv.workspace]
members = ["child"]
"#
})?;
let root_src = context.temp_dir.child("src").child("root");
root_src.create_dir_all()?;
root_src.child("__init__.py").write_str(indoc! {
r"
def main():
import child
print(child.MESSAGE)
"
})?;
let child = context.temp_dir.child("child");
child.create_dir_all()?;
child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let child_src = child.child("src").child("child");
child_src.create_dir_all()?;
child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
uv_snapshot!(context.filters(), context.tool_install()
.arg(context.temp_dir.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ child==0.1.0 (from file://[TEMP_DIR]/child)
+ root==0.1.0 (from file://[TEMP_DIR]/)
Installed 1 executable: root_cli
");
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
child_src
.child("__init__.py")
.write_str("MESSAGE = 'CHANGED'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
Ok(())
}
#[test]
fn tool_install_workspace_members_honor_editable_flag() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let root_pyproject = context.temp_dir.child("pyproject.toml");
root_pyproject.write_str(indoc! {
r#"
[project]
name = "root"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child"]
[project.scripts]
root_cli = "root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
child = { workspace = true }
[tool.uv.workspace]
members = ["child"]
"#
})?;
let root_src = context.temp_dir.child("src").child("root");
root_src.create_dir_all()?;
root_src.child("__init__.py").write_str(indoc! {
r"
def main():
import child
print(child.MESSAGE)
"
})?;
let child = context.temp_dir.child("child");
child.create_dir_all()?;
child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let child_src = child.child("src").child("child");
child_src.create_dir_all()?;
child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
uv_snapshot!(context.filters(), context.tool_install()
.arg("--editable")
.arg(context.temp_dir.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ child==0.1.0 (from file://[TEMP_DIR]/child)
+ root==0.1.0 (from file://[TEMP_DIR]/)
Installed 1 executable: root_cli
");
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
child_src
.child("__init__.py")
.write_str("MESSAGE = 'CHANGED'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
CHANGED
----- stderr -----
");
Ok(())
}
#[test]
fn tool_install_workspace_members_honor_source_editable_flag() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let root_pyproject = context.temp_dir.child("pyproject.toml");
root_pyproject.write_str(indoc! {
r#"
[project]
name = "root"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child"]
[project.scripts]
root_cli = "root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
child = { workspace = true, editable = true }
[tool.uv.workspace]
members = ["child"]
"#
})?;
let root_src = context.temp_dir.child("src").child("root");
root_src.create_dir_all()?;
root_src.child("__init__.py").write_str(indoc! {
r"
ROOT_MESSAGE = 'ROOT'
def main():
import child
print(f'{ROOT_MESSAGE} {child.MESSAGE}')
"
})?;
let child = context.temp_dir.child("child");
child.create_dir_all()?;
child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let child_src = child.child("src").child("child");
child_src.create_dir_all()?;
child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
uv_snapshot!(context.filters(), context.tool_install()
.arg(context.temp_dir.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ child==0.1.0 (from file://[TEMP_DIR]/child)
+ root==0.1.0 (from file://[TEMP_DIR]/)
Installed 1 executable: root_cli
");
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
ROOT OK
----- stderr -----
");
root_src.child("__init__.py").write_str(indoc! {
r"
ROOT_MESSAGE = 'CHANGED'
def main():
import child
print(f'{ROOT_MESSAGE} {child.MESSAGE}')
"
})?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
ROOT OK
----- stderr -----
");
child_src
.child("__init__.py")
.write_str("MESSAGE = 'CHANGED'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
ROOT CHANGED
----- stderr -----
");
Ok(())
}
#[test]
fn tool_install_with_compatible_build_constraints() -> Result<()> {
let context = uv_test::test_context!("3.9")
+422
View File
@@ -1,6 +1,9 @@
use std::process::Command;
use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::prelude::*;
use indoc::indoc;
use insta::assert_snapshot;
use uv_static::EnvVars;
@@ -92,6 +95,425 @@ fn tool_upgrade_empty() {
");
}
#[test]
fn tool_upgrade_preserves_workspace_member_editability() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let root_pyproject = context.temp_dir.child("pyproject.toml");
root_pyproject.write_str(indoc! {
r#"
[project]
name = "root"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child"]
[project.scripts]
root_cli = "root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
child = { workspace = true }
[tool.uv.workspace]
members = ["child"]
"#
})?;
let root_src = context.temp_dir.child("src").child("root");
root_src.create_dir_all()?;
root_src.child("__init__.py").write_str(indoc! {
r"
def main():
import child
print(child.MESSAGE)
"
})?;
let child = context.temp_dir.child("child");
child.create_dir_all()?;
child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let child_src = child.child("src").child("child");
child_src.create_dir_all()?;
child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
let status = context
.tool_install()
.arg(context.temp_dir.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool install");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
child_src
.child("__init__.py")
.write_str("MESSAGE = 'PRE-UPGRADE'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
let status = context
.tool_upgrade()
.arg("root")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool upgrade");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
child_src
.child("__init__.py")
.write_str("MESSAGE = 'POST-UPGRADE'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
OK
----- stderr -----
");
Ok(())
}
#[test]
fn tool_upgrade_preserves_mixed_workspace_member_editability() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let tool_root = context.temp_dir.child("tool-root");
tool_root.create_dir_all()?;
tool_root.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "tool-root"
version = "0.1.0"
requires-python = ">=3.12"
[project.scripts]
root_cli = "tool_root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let tool_root_src = tool_root.child("src").child("tool_root");
tool_root_src.create_dir_all()?;
tool_root_src.child("__init__.py").write_str(indoc! {
r#"
def main():
import importlib.metadata
import other_child
print(f"{importlib.metadata.version('tool-root')} {other_child.MESSAGE}")
"#
})?;
let other_workspace = context.temp_dir.child("other-workspace");
other_workspace.create_dir_all()?;
other_workspace
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "other-workspace"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["other-child"]
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
other-child = { workspace = true }
[tool.uv.workspace]
members = ["packages/*"]
"#})?;
let other_workspace_src = other_workspace.child("src").child("other_workspace");
other_workspace_src.create_dir_all()?;
other_workspace_src.child("__init__.py").touch()?;
let other_child = other_workspace.child("packages").child("other-child");
other_child.create_dir_all()?;
other_child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "other-child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let other_child_src = other_child.child("src").child("other_child");
other_child_src.create_dir_all()?;
other_child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
let status = context
.tool_install()
.arg("--with-editable")
.arg(other_workspace.path())
.arg(tool_root.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool install");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.0 OK
----- stderr -----
");
tool_root.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "tool-root"
version = "0.1.1"
requires-python = ">=3.12"
[project.scripts]
root_cli = "tool_root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let status = context
.tool_upgrade()
.arg("tool-root")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool upgrade");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.1 OK
----- stderr -----
");
other_child_src
.child("__init__.py")
.write_str("MESSAGE = 'POST-UPGRADE'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.1 POST-UPGRADE
----- stderr -----
");
Ok(())
}
#[test]
fn tool_upgrade_preserves_mixed_workspace_member_non_editability() -> Result<()> {
let context = uv_test::test_context!("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let tool_root = context.temp_dir.child("tool-root");
tool_root.create_dir_all()?;
tool_root.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "tool-root"
version = "0.1.0"
requires-python = ">=3.12"
[project.scripts]
root_cli = "tool_root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let tool_root_src = tool_root.child("src").child("tool_root");
tool_root_src.create_dir_all()?;
tool_root_src.child("__init__.py").write_str(indoc! {
r#"
def main():
import importlib.metadata
import other_child
print(f"{importlib.metadata.version('tool-root')} {other_child.MESSAGE}")
"#
})?;
let other_workspace = context.temp_dir.child("other-workspace");
other_workspace.create_dir_all()?;
other_workspace
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "other-workspace"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["other-child"]
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
[tool.uv.sources]
other-child = { workspace = true }
[tool.uv.workspace]
members = ["packages/*"]
"#})?;
let other_workspace_src = other_workspace.child("src").child("other_workspace");
other_workspace_src.create_dir_all()?;
other_workspace_src.child("__init__.py").touch()?;
let other_child = other_workspace.child("packages").child("other-child");
other_child.create_dir_all()?;
other_child.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "other-child"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let other_child_src = other_child.child("src").child("other_child");
other_child_src.create_dir_all()?;
other_child_src
.child("__init__.py")
.write_str("MESSAGE = 'OK'\n")?;
let status = context
.tool_install()
.arg("--editable")
.arg("--with")
.arg(other_workspace.path())
.arg(tool_root.path())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool install");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.0 OK
----- stderr -----
");
tool_root.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "tool-root"
version = "0.1.1"
requires-python = ">=3.12"
[project.scripts]
root_cli = "tool_root:main"
[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
let status = context
.tool_upgrade()
.arg("tool-root")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.status()
.expect("failed to run uv tool upgrade");
assert!(status.success());
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.1 OK
----- stderr -----
");
other_child_src
.child("__init__.py")
.write_str("MESSAGE = 'POST-UPGRADE'\n")?;
uv_snapshot!(context.filters(), Command::new("root_cli").env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
0.1.1 OK
----- stderr -----
");
Ok(())
}
#[test]
fn tool_upgrade_name() {
let context = uv_test::test_context!("3.12")