From accfb488766f02c7c975f32243c82f1eed14b68f Mon Sep 17 00:00:00 2001 From: chisato <67509746+yumeminami@users.noreply.github.com> Date: Wed, 17 Sep 2025 19:35:32 +0800 Subject: [PATCH] Fix `uv sync --no-sources` not switching from editable to registry installations (#15234) ## Summary Fixes issue #15190 where `uv sync --no-sources` fails to switch from editable to registry package installations. The problem occurred because the installer's satisfaction check didn't consider the `--no-sources` flag when determining if an existing editable installation was compatible with a registry requirement. ## Solution Modified `RequirementSatisfaction::check()` to reject non-registry installations when `SourceStrategy::Disabled` and the requirement is from registry. Added `SourceStrategy` parameter threading through the entire call chain from commands to the satisfaction check to ensure consistent behavior between `uv sync --no-sources` and `uv pip install --no-sources`. --------- Co-authored-by: Charlie Marsh --- crates/uv-dispatch/src/lib.rs | 3 +- crates/uv-installer/src/lib.rs | 4 +- crates/uv-installer/src/plan.rs | 4 +- crates/uv-installer/src/satisfies.rs | 23 +++++ crates/uv-installer/src/site_packages.rs | 26 +++++ crates/uv-resolver/src/candidate_selector.rs | 28 +++++- crates/uv-resolver/src/resolver/mod.rs | 8 +- crates/uv/src/commands/pip/install.rs | 4 +- crates/uv/src/commands/pip/operations.rs | 4 +- crates/uv/src/commands/pip/sync.rs | 3 +- crates/uv/src/commands/project/mod.rs | 5 +- crates/uv/src/commands/project/run.rs | 3 +- crates/uv/src/commands/project/sync.rs | 3 +- crates/uv/src/commands/tool/install.rs | 3 +- crates/uv/src/commands/tool/run.rs | 3 +- crates/uv/tests/it/pip_install.rs | 64 ++++++++++++ crates/uv/tests/it/sync.rs | 100 +++++++++++++++++++ 17 files changed, 271 insertions(+), 17 deletions(-) diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index a20c26ffb..1b8570bab 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -28,7 +28,7 @@ use uv_distribution_types::{ PackageConfigSettings, Requirement, Resolution, SourceDist, VersionOrUrlRef, }; use uv_git::GitResolver; -use uv_installer::{Installer, Plan, Planner, Preparer, SitePackages}; +use uv_installer::{InstallationStrategy, Installer, Plan, Planner, Preparer, SitePackages}; use uv_preview::Preview; use uv_pypi_types::Conflicts; use uv_python::{Interpreter, PythonEnvironment}; @@ -316,6 +316,7 @@ impl BuildContext for BuildDispatch<'_> { extraneous: _, } = Planner::new(resolution).build( site_packages, + InstallationStrategy::Permissive, &Reinstall::default(), self.build_options, self.hasher, diff --git a/crates/uv-installer/src/lib.rs b/crates/uv-installer/src/lib.rs index 414f8f245..0f45f9bdf 100644 --- a/crates/uv-installer/src/lib.rs +++ b/crates/uv-installer/src/lib.rs @@ -2,7 +2,9 @@ pub use compile::{CompileError, compile_tree}; pub use installer::{Installer, Reporter as InstallReporter}; pub use plan::{Plan, Planner}; pub use preparer::{Error as PrepareError, Preparer, Reporter as PrepareReporter}; -pub use site_packages::{SatisfiesResult, SitePackages, SitePackagesDiagnostic}; +pub use site_packages::{ + InstallationStrategy, SatisfiesResult, SitePackages, SitePackagesDiagnostic, +}; pub use uninstall::{UninstallError, uninstall}; mod compile; diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index bdadddbc1..1941a8371 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -23,8 +23,8 @@ use uv_pypi_types::VerbatimParsedUrl; use uv_python::PythonEnvironment; use uv_types::HashStrategy; -use crate::SitePackages; use crate::satisfies::RequirementSatisfaction; +use crate::{InstallationStrategy, SitePackages}; /// A planner to generate an [`Plan`] based on a set of requirements. #[derive(Debug)] @@ -52,6 +52,7 @@ impl<'a> Planner<'a> { pub fn build( self, mut site_packages: SitePackages, + installation: InstallationStrategy, reinstall: &Reinstall, build_options: &BuildOptions, hasher: &HashStrategy, @@ -125,6 +126,7 @@ impl<'a> Planner<'a> { dist.name(), installed, &source, + installation, tags, config_settings, config_settings_package, diff --git a/crates/uv-installer/src/satisfies.rs b/crates/uv-installer/src/satisfies.rs index 1fb177f9c..5bcf82483 100644 --- a/crates/uv-installer/src/satisfies.rs +++ b/crates/uv-installer/src/satisfies.rs @@ -19,6 +19,8 @@ use uv_normalize::PackageName; use uv_platform_tags::{IncompatibleTag, TagCompatibility, Tags}; use uv_pypi_types::{DirInfo, DirectUrl, VcsInfo, VcsKind}; +use crate::InstallationStrategy; + #[derive(Debug, Copy, Clone)] pub(crate) enum RequirementSatisfaction { Mismatch, @@ -35,6 +37,7 @@ impl RequirementSatisfaction { name: &PackageName, distribution: &InstalledDist, source: &RequirementSource, + installation: InstallationStrategy, tags: &Tags, config_settings: &ConfigSettings, config_settings_package: &PackageConfigSettings, @@ -67,6 +70,26 @@ impl RequirementSatisfaction { match source { // If the requirement comes from a registry, check by name. RequirementSource::Registry { specifier, .. } => { + // If the installed distribution is _not_ from a registry, reject it if and only if + // we're in a stateless install. + // + // For example: the `uv pip` CLI is stateful, in that it "respects" + // already-installed packages in the virtual environment. So if you run `uv pip + // install ./path/to/idna`, and then `uv pip install anyio` (which depends on + // `idna`), we'll "accept" the already-installed `idna` even though it is implicitly + // being "required" as a registry package. + // + // The `uv sync` CLI is stateless, in that all requirements must be defined + // declaratively ahead-of-time. So if you `uv sync` to install `./path/to/idna` and + // later `uv sync` to install `anyio`, we'll know (during that second sync) if the + // already-installed `idna` should come from the registry or not. + if installation == InstallationStrategy::Strict { + if !matches!(distribution.kind, InstalledDistKind::Registry { .. }) { + debug!("Distribution type mismatch for {name}: {distribution:?}"); + return Self::Mismatch; + } + } + if !specifier.contains(distribution.version()) { return Self::Mismatch; } diff --git a/crates/uv-installer/src/site_packages.rs b/crates/uv-installer/src/site_packages.rs index 2c2b64eac..84dc7f66b 100644 --- a/crates/uv-installer/src/site_packages.rs +++ b/crates/uv-installer/src/site_packages.rs @@ -314,6 +314,7 @@ impl SitePackages { requirements: &[UnresolvedRequirementSpecification], constraints: &[NameRequirementSpecification], overrides: &[UnresolvedRequirementSpecification], + installation: InstallationStrategy, markers: &ResolverMarkerEnvironment, tags: &Tags, config_settings: &ConfigSettings, @@ -404,6 +405,7 @@ impl SitePackages { requirements.iter().map(Cow::as_ref), constraints.iter().map(|constraint| &constraint.requirement), overrides.iter().map(Cow::as_ref), + installation, markers, tags, config_settings, @@ -419,6 +421,7 @@ impl SitePackages { requirements: impl ExactSizeIterator, constraints: impl Iterator, overrides: impl Iterator, + installation: InstallationStrategy, markers: &ResolverMarkerEnvironment, tags: &Tags, config_settings: &ConfigSettings, @@ -482,6 +485,7 @@ impl SitePackages { name, distribution, &requirement.source, + installation, tags, config_settings, config_settings_package, @@ -504,6 +508,7 @@ impl SitePackages { name, distribution, &constraint.source, + installation, tags, config_settings, config_settings_package, @@ -560,6 +565,27 @@ impl SitePackages { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallationStrategy { + /// A permissive installation strategy, which accepts existing installations even if the source + /// type differs, as in the `pip` and `uv pip` CLIs. + /// + /// In this strategy, packages that are already installed in the environment may be reused if + /// they implicitly match the requirements. For example, if the user installs `./path/to/idna`, + /// then runs `uv pip install anyio` (which depends on `idna`), the existing `idna` installation + /// will be reused if its version matches the requirement, even though it was installed from a + /// path and is being implicitly requested from a registry. + Permissive, + + /// A strict installation strategy, which requires that existing installations match the source + /// type, as in the `uv sync` CLI. + /// + /// This strategy enforces that the installation source must match the requirement source. + /// It prevents reusing packages that were installed from different sources, ensuring + /// declarative and reproducible environments. + Strict, +} + /// We check if all requirements are already satisfied, recursing through the requirements tree. #[derive(Debug)] pub enum SatisfiesResult { diff --git a/crates/uv-resolver/src/candidate_selector.rs b/crates/uv-resolver/src/candidate_selector.rs index 6f92a3f6b..01f89cd7c 100644 --- a/crates/uv-resolver/src/candidate_selector.rs +++ b/crates/uv-resolver/src/candidate_selector.rs @@ -6,8 +6,10 @@ use pubgrub::Range; use smallvec::SmallVec; use tracing::{debug, trace}; -use uv_configuration::IndexStrategy; -use uv_distribution_types::{CompatibleDist, IncompatibleDist, IncompatibleSource, IndexUrl}; +use uv_configuration::{IndexStrategy, SourceStrategy}; +use uv_distribution_types::{ + CompatibleDist, IncompatibleDist, IncompatibleSource, IndexUrl, InstalledDistKind, +}; use uv_distribution_types::{DistributionMetadata, IncompatibleWheel, Name, PrioritizedDist}; use uv_normalize::PackageName; use uv_pep440::Version; @@ -26,6 +28,7 @@ pub(crate) struct CandidateSelector { resolution_strategy: ResolutionStrategy, prerelease_strategy: PrereleaseStrategy, index_strategy: IndexStrategy, + source_strategy: SourceStrategy, } impl CandidateSelector { @@ -34,6 +37,7 @@ impl CandidateSelector { options: &Options, manifest: &Manifest, env: &ResolverEnvironment, + source_strategy: SourceStrategy, ) -> Self { Self { resolution_strategy: ResolutionStrategy::from_mode( @@ -49,6 +53,7 @@ impl CandidateSelector { options.dependency_mode, ), index_strategy: options.index_strategy, + source_strategy, } } @@ -119,7 +124,13 @@ impl CandidateSelector { let installed = if reinstall { None } else { - Self::get_installed(package_name, range, installed_packages, tags) + Self::get_installed( + package_name, + range, + installed_packages, + tags, + self.source_strategy, + ) }; // If we're not upgrading, we should prefer the already-installed distribution. @@ -369,6 +380,7 @@ impl CandidateSelector { range: &Range, installed_packages: &'a InstalledPackages, tags: Option<&'a Tags>, + source_strategy: SourceStrategy, ) -> Option> { let installed_dists = installed_packages.get_packages(package_name); match installed_dists.as_slice() { @@ -381,6 +393,16 @@ impl CandidateSelector { return None; } + // When sources are disabled, only allow registry installations to be reused + if matches!(source_strategy, SourceStrategy::Disabled) { + if !matches!(dist.kind, InstalledDistKind::Registry(_)) { + debug!( + "Source strategy is disabled, rejecting non-registry installed distribution: {dist}" + ); + return None; + } + } + // Verify that the installed distribution is compatible with the environment. if tags.is_some_and(|tags| { let Ok(Some(wheel_tags)) = dist.read_tags() else { diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index a3e7e1714..c7d4a2523 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -20,7 +20,7 @@ use tokio::sync::oneshot; use tokio_stream::wrappers::ReceiverStream; use tracing::{Level, debug, info, instrument, trace, warn}; -use uv_configuration::{Constraints, Overrides}; +use uv_configuration::{Constraints, Overrides, SourceStrategy}; use uv_distribution::{ArchiveMetadata, DistributionDatabase}; use uv_distribution_types::{ BuiltDist, CompatibleDist, DerivationChain, Dist, DistErrorKind, DistributionMetadata, @@ -36,6 +36,7 @@ use uv_pep508::{ }; use uv_platform_tags::Tags; use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKindRef, Conflicts, VerbatimParsedUrl}; +use uv_torch::TorchStrategy; use uv_types::{BuildContext, HashStrategy, InstalledPackagesProvider}; use uv_warnings::warn_user_once; @@ -82,7 +83,6 @@ use crate::{ marker, }; pub(crate) use provider::MetadataUnavailable; -use uv_torch::TorchStrategy; mod availability; mod batch_prefetch; @@ -201,6 +201,7 @@ impl<'a, Context: BuildContext, InstalledPackages: InstalledPackagesProvider> build_context.git(), build_context.capabilities(), build_context.locations(), + build_context.sources(), provider, installed_packages, ) @@ -224,6 +225,7 @@ impl git: &GitResolver, capabilities: &IndexCapabilities, locations: &IndexLocations, + source_strategy: SourceStrategy, provider: Provider, installed_packages: InstalledPackages, ) -> Result { @@ -231,7 +233,7 @@ impl index: index.clone(), git: git.clone(), capabilities: capabilities.clone(), - selector: CandidateSelector::for_resolution(&options, &manifest, &env), + selector: CandidateSelector::for_resolution(&options, &manifest, &env, source_strategy), dependency_mode: options.dependency_mode, urls: Urls::from_manifest(&manifest, &env, git, options.dependency_mode), indexes: Indexes::from_manifest(&manifest, &env, options.dependency_mode), diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index fa3dfb53f..44146bbea 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -22,7 +22,7 @@ use uv_distribution_types::{ }; use uv_fs::Simplified; use uv_install_wheel::LinkMode; -use uv_installer::{SatisfiesResult, SitePackages}; +use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::{DefaultExtras, DefaultGroups}; use uv_preview::{Preview, PreviewFeatures}; use uv_pypi_types::Conflicts; @@ -289,6 +289,7 @@ pub(crate) async fn pip_install( &requirements, &constraints, &overrides, + InstallationStrategy::Permissive, &marker_env, &tags, config_settings, @@ -602,6 +603,7 @@ pub(crate) async fn pip_install( match operations::install( &resolution, site_packages, + InstallationStrategy::Permissive, modifications, &reinstall, &build_options, diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index c5cbe730a..118606ab8 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -25,7 +25,7 @@ use uv_distribution_types::{ use uv_distribution_types::{DistributionMetadata, InstalledMetadata, Name, Resolution}; use uv_fs::Simplified; use uv_install_wheel::LinkMode; -use uv_installer::{Plan, Planner, Preparer, SitePackages}; +use uv_installer::{InstallationStrategy, Plan, Planner, Preparer, SitePackages}; use uv_normalize::PackageName; use uv_pep508::{MarkerEnvironment, RequirementOrigin}; use uv_platform_tags::Tags; @@ -436,6 +436,7 @@ impl Changelog { pub(crate) async fn install( resolution: &Resolution, site_packages: SitePackages, + installation: InstallationStrategy, modifications: Modifications, reinstall: &Reinstall, build_options: &BuildOptions, @@ -462,6 +463,7 @@ pub(crate) async fn install( let plan = Planner::new(resolution) .build( site_packages, + installation, reinstall, build_options, hasher, diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 23406972d..0cc7c9d9a 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -20,7 +20,7 @@ use uv_distribution_types::{ }; use uv_fs::Simplified; use uv_install_wheel::LinkMode; -use uv_installer::SitePackages; +use uv_installer::{InstallationStrategy, SitePackages}; use uv_normalize::{DefaultExtras, DefaultGroups}; use uv_preview::{Preview, PreviewFeatures}; use uv_pypi_types::Conflicts; @@ -533,6 +533,7 @@ pub(crate) async fn pip_sync( match operations::install( &resolution, site_packages, + InstallationStrategy::Permissive, Modifications::Exact, &reinstall, &build_options, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 2644402ee..cc066454b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -23,7 +23,7 @@ use uv_distribution_types::{ }; use uv_fs::{CWD, LockedFile, Simplified}; use uv_git::ResolvedRepositoryReference; -use uv_installer::{SatisfiesResult, SitePackages}; +use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, PackageName}; use uv_pep440::{TildeVersionSpecifier, Version, VersionSpecifiers}; use uv_pep508::MarkerTreeContents; @@ -2132,6 +2132,7 @@ pub(crate) async fn sync_environment( pip::operations::install( resolution, site_packages, + InstallationStrategy::Permissive, modifications, reinstall, build_options, @@ -2251,6 +2252,7 @@ pub(crate) async fn update_environment( &requirements, &constraints, &overrides, + InstallationStrategy::Permissive, &marker_env, &tags, config_setting, @@ -2396,6 +2398,7 @@ pub(crate) async fn update_environment( let changelog = pip::operations::install( &resolution, site_packages, + InstallationStrategy::Permissive, modifications, reinstall, build_options, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index f3ed2443c..f9deeb431 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -25,7 +25,7 @@ use uv_distribution::LoweredExtraBuildDependencies; use uv_distribution_types::Requirement; use uv_fs::which::is_executable; use uv_fs::{PythonExt, Simplified, create_symlink}; -use uv_installer::{SatisfiesResult, SitePackages}; +use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_preview::Preview; use uv_python::{ @@ -1360,6 +1360,7 @@ fn can_skip_ephemeral( &spec.requirements, &spec.constraints, &spec.overrides, + InstallationStrategy::Permissive, &markers, tags, config_setting, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index c5511134d..f6190e1cd 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -22,7 +22,7 @@ use uv_distribution_types::{ DirectorySourceDist, Dist, Index, Requirement, Resolution, ResolvedDist, SourceDist, }; use uv_fs::{PortablePathBuf, Simplified}; -use uv_installer::SitePackages; +use uv_installer::{InstallationStrategy, SitePackages}; use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_pep508::{MarkerTree, VersionOrUrl}; use uv_preview::{Preview, PreviewFeatures}; @@ -782,6 +782,7 @@ pub(super) async fn do_sync( operations::install( &resolution, site_packages, + InstallationStrategy::Strict, modifications, reinstall, build_options, diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 463e4a6bd..04b5ef884 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -14,7 +14,7 @@ use uv_distribution_types::{ ExtraBuildRequires, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirementSpecification, }; -use uv_installer::{SatisfiesResult, SitePackages}; +use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; @@ -405,6 +405,7 @@ pub(crate) async fn install( requirements.iter(), constraints.iter(), overrides.iter(), + InstallationStrategy::Permissive, &markers, &tags, config_setting, diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 1bd660524..dee2c11c0 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -26,7 +26,7 @@ use uv_distribution_types::{ UnresolvedRequirement, UnresolvedRequirementSpecification, }; use uv_fs::Simplified; -use uv_installer::{SatisfiesResult, SitePackages}; +use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; @@ -973,6 +973,7 @@ async fn get_or_create_environment( requirements.iter(), constraints.iter(), overrides.iter(), + InstallationStrategy::Permissive, &markers, &tags, config_setting, diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index bdcb1537a..d2c7b32dc 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -12845,3 +12845,67 @@ fn switch_platform() { " ); } + +/// `uv pip install --no-sources` should allow non-registry installations, for compatibility with `pip install`. +/// +/// See: +#[test] +fn pip_install_no_sources_editable_to_registry_switch() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create a simple local package. + let local_pkg = context.temp_dir.child("local_pkg"); + local_pkg.create_dir_all()?; + + local_pkg.child("pyproject.toml").write_str( + r#" + [project] + name = "iniconfig" + version = "2.0.0" + description = "Local test package" + requires-python = ">=3.7" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#, + )?; + + local_pkg.child("src").child("iniconfig").create_dir_all()?; + local_pkg + .child("src") + .child("iniconfig") + .child("__init__.py") + .write_str("__version__ = '2.0.0'")?; + + // Step 1: Install as editable first. + uv_snapshot!(context.filters(), context.pip_install() + .arg("--editable") + .arg("./local_pkg"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 (from file://[TEMP_DIR]/local_pkg) + " + ); + + // Step 2: Use `--no-sources`; we should retain the package. + uv_snapshot!(context.filters(), context.pip_install() + .arg("iniconfig") + .arg("--no-sources"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + " + ); + + Ok(()) +} diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 40ce60eb9..f41343b8f 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -14151,3 +14151,103 @@ fn only_group_and_extra_conflict() -> Result<()> { Ok(()) } + +/// `uv sync --no-sources` should consistently switch from editable to package installation. +/// +/// See: +#[test] +fn sync_no_sources_editable_to_package_switch() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create a local package that will be used as editable dependency. + let local_dep = context.temp_dir.child("local_dep"); + local_dep.create_dir_all()?; + + let local_dep_pyproject = local_dep.child("pyproject.toml"); + local_dep_pyproject.write_str( + r#" + [project] + name = "anyio" + version = "4.3.0" + description = "Local test package mimicking anyio" + requires-python = ">=3.8" + + [build-system] + requires = ["setuptools>=61", "wheel"] + build-backend = "setuptools.build_meta" + "#, + )?; + + // Create main project with editable source for the local dependency. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "test_no_sources" + version = "0.0.1" + requires-python = ">=3.12" + dependencies = ["anyio"] + + [tool.uv.sources] + anyio = { path = "./local_dep", editable = true } + + [build-system] + requires = ["setuptools>=67"] + build-backend = "setuptools.build_meta" + + [tool.setuptools.packages.find] + exclude = ["local_dep*"] + "#, + )?; + + // Step 1: `uv sync --no-sources` should install `anyio` from PyPI. + uv_snapshot!(context.filters(), context.sync().arg("--no-sources"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + + test-no-sources==0.0.1 (from file://[TEMP_DIR]/) + "); + + // Step 2: `uv sync` should switch to an editable installation. + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 3 packages in [TIME] + Installed 1 package in [TIME] + - anyio==4.3.0 + + anyio==4.3.0 (from file://[TEMP_DIR]/local_dep) + - idna==3.6 + - sniffio==1.3.1 + "); + + // Step 3: `uv sync --no-sources` again should switch back to PyPI package. + uv_snapshot!(context.filters(), context.sync().arg("--no-sources"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 3 packages in [TIME] + - anyio==4.3.0 (from file://[TEMP_DIR]/local_dep) + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "); + + Ok(()) +}