diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index a966d5df085a3..edea18cde84f2 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1167,6 +1167,9 @@ pub enum ProjectCommand { after_long_help = "" )] Lock(LockArgs), + /// Upgrade a dependency in the project. + #[command(hide = true)] + Upgrade(UpgradeArgs), /// Export the project's lockfile to an alternate format. /// /// At present, `requirements.txt`, `pylock.toml` (PEP 751) and CycloneDX v1.5 JSON output @@ -4269,6 +4272,13 @@ pub struct LockArgs { pub python: Option>, } +#[derive(Args)] +pub struct UpgradeArgs { + /// The package to upgrade. + #[arg(value_hint = ValueHint::Other)] + pub package: PackageName, +} + #[derive(Args)] #[command(group = clap::ArgGroup::new("sources").required(true).multiple(true))] pub struct AddArgs { diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index 54a9faaeb8ae4..c0f627485f900 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -94,7 +94,7 @@ impl Metadata { /// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory /// dependencies. - pub(crate) async fn from_workspace( + pub async fn from_workspace( metadata: ResolutionMetadata, install_path: &Path, git_source: Option<&GitWorkspaceMember<'_>>, diff --git a/crates/uv-test/src/lib.rs b/crates/uv-test/src/lib.rs index 5f0e632dc681b..1575fd3f40417 100755 --- a/crates/uv-test/src/lib.rs +++ b/crates/uv-test/src/lib.rs @@ -1355,6 +1355,14 @@ impl TestContext { command } + /// Create a `uv upgrade` command with options shared across scenarios. + pub fn upgrade(&self) -> Command { + let mut command = self.new_command(); + command.arg("upgrade"); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv audit` command with options shared across scenarios. pub fn audit(&self) -> Command { let mut command = self.new_command(); diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index cf6bb94cd4c2c..3da6f7fa80c4f 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -349,6 +349,50 @@ impl PyProjectTomlMut { Ok(edit) } + /// Replaces a dependency in `project.dependencies` without modifying its source. + /// + /// Returns `Some` if the dependency was replaced, or `None` if it was not found. + pub fn replace_dependency( + &mut self, + req: &Requirement, + raw: bool, + ) -> Result, Error> { + let Some(dependencies) = self + .project_mut()? + .and_then(|project| project.get_mut("dependencies")) + .map(|dependencies| { + dependencies + .as_array_mut() + .ok_or(Error::MalformedDependencies) + }) + .transpose()? + else { + return Ok(None); + }; + let mut to_replace = find_dependencies(&req.name, Some(&req.marker), dependencies); + + match to_replace.as_slice() { + [] => Ok(None), + [_] => { + let (index, _) = to_replace.remove(0); + let req_string = if raw { + req.displayable_with_credentials().to_string() + } else { + req.to_string() + }; + dependencies.replace(index, req_string); + Ok(Some(ArrayEdit::Update(index))) + } + _ => Err(Error::Ambiguous { + package_name: req.name.clone(), + requirements: to_replace + .into_iter() + .map(|(_, requirement)| requirement) + .collect(), + }), + } + } + /// Adds a development dependency to `tool.uv.dev-dependencies`. /// /// Returns `true` if the dependency was added, `false` if it was updated. @@ -1782,12 +1826,17 @@ fn split_specifiers(req: &str) -> (&str, &str) { #[cfg(test)] mod test { - use super::{AddBoundsKind, reformat_array_multiline, remove_dependency, split_specifiers}; + use super::{ + AddBoundsKind, DependencyTarget, PyProjectTomlMut, reformat_array_multiline, + remove_dependency, split_specifiers, + }; + use anyhow::Result; use insta::assert_snapshot; use std::str::FromStr; use toml_edit::DocumentMut; use uv_normalize::PackageName; use uv_pep440::Version; + use uv_pep508::Requirement; #[test] fn split() { @@ -1962,6 +2011,35 @@ dependencies = [ } } + #[test] + fn replace_dependency_preserves_source() -> Result<()> { + let mut pyproject = PyProjectTomlMut::from_toml( + r#"[project] +dependencies = ["anyio<=2"] + +[tool.uv.sources] +anyio = { index = "internal" } + "#, + DependencyTarget::PyProjectToml, + )?; + let requirement = Requirement::from_str("anyio")?; + + let replaced = pyproject.replace_dependency(&requirement, false)?; + assert!(replaced.is_some()); + + assert_snapshot!( + pyproject.to_string(), + @r#" +[project] +dependencies = ["anyio"] + +[tool.uv.sources] +anyio = { index = "internal" } +"# + ); + Ok(()) + } + #[test] fn remove_preserves_end_of_line_comment_on_previous_item() { let toml = r#" diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 077417f1374d6..5e4a9fb43f525 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -41,6 +41,7 @@ pub(crate) use project::remove::remove; pub(crate) use project::run::{ParsedRunCommand, RunCommand, run}; pub(crate) use project::sync::sync; pub(crate) use project::tree::tree; +pub(crate) use project::upgrade::upgrade; pub(crate) use project::version::{project_version, self_version}; pub(crate) use publish::publish; pub(crate) use python::dir::dir as python_dir; diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index d888ffe41f722..d5c6f7d42d795 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1445,7 +1445,7 @@ impl ValidatedLock { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -struct LockEventVersion<'lock> { +pub(super) struct LockEventVersion<'lock> { /// The version of the package, or `None` if the package has a dynamic version. version: Option<&'lock Version>, /// The short Git SHA of the package, if it was installed from a Git repository. @@ -1474,7 +1474,7 @@ impl std::fmt::Display for LockEventVersion<'_> { /// A modification to a lockfile. #[derive(Debug, Clone)] -enum LockEvent<'lock> { +pub(super) enum LockEvent<'lock> { Update( DryRun, PackageName, @@ -1487,7 +1487,7 @@ enum LockEvent<'lock> { impl<'lock> LockEvent<'lock> { /// Detect the change events between an (optional) existing and updated lockfile. - fn detect_changes( + pub(super) fn detect_changes( existing_lock: Option<&'lock Lock>, new_lock: &'lock Lock, dry_run: DryRun, @@ -1548,6 +1548,14 @@ impl<'lock> LockEvent<'lock> { } }) } + + pub(super) fn package(&self) -> &PackageName { + match self { + Self::Update(_, package, ..) + | Self::Add(_, package, ..) + | Self::Remove(_, package, ..) => package, + } + } } impl std::fmt::Display for LockEvent<'_> { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 5a086abdec5d8..75b5fccf5c0ff 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -78,6 +78,7 @@ pub(crate) mod remove; pub(crate) mod run; pub(crate) mod sync; pub(crate) mod tree; +pub(crate) mod upgrade; pub(crate) mod version; /// The source of a missing lockfile error. diff --git a/crates/uv/src/commands/project/upgrade.rs b/crates/uv/src/commands/project/upgrade.rs new file mode 100644 index 0000000000000..103b2f9b496c2 --- /dev/null +++ b/crates/uv/src/commands/project/upgrade.rs @@ -0,0 +1,358 @@ +use std::fmt::Write; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Context, Result, anyhow, bail}; +use uv_cache::{Cache, Refresh}; +use uv_client::BaseClientBuilder; +use uv_configuration::{Concurrency, DependencyGroupsWithDefaults, DryRun}; +use uv_distribution::{ArchiveMetadata, Metadata}; +use uv_distribution_types::Identifier; +use uv_normalize::PackageName; +use uv_pep440::{Operator, VersionSpecifier, VersionSpecifiers}; +use uv_pep508::{Requirement, VerbatimUrl, VersionOrUrl}; +use uv_preview::Preview; +use uv_pypi_types::{PyProjectToml, ResolutionMetadata, VerbatimParsedUrl}; +use uv_python::{PythonDownloads, PythonPreference}; +use uv_redacted::DisplaySafeUrl; +use uv_resolver::MetadataResponse; +use uv_settings::PythonInstallMirrors; +use uv_workspace::pyproject::Source; +use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError}; + +use crate::commands::pip::loggers::DefaultResolveLogger; +use crate::commands::project::lock::{LockEvent, LockMode, LockOperation, LockResult}; +use crate::commands::project::lock_target::LockTarget; +use crate::commands::project::{ProjectError, ProjectInterpreter, UniversalState, WorkspacePython}; +use crate::commands::{ExitStatus, diagnostics}; +use crate::printer::Printer; +use crate::settings::ResolverSettings; + +pub(crate) async fn upgrade( + project_dir: &Path, + package: PackageName, + install_mirrors: PythonInstallMirrors, + settings: ResolverSettings, + client_builder: BaseClientBuilder<'_>, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + concurrency: Concurrency, + no_config: bool, + cache: &Cache, + workspace_cache: &WorkspaceCache, + printer: Printer, + preview: Preview, +) -> Result { + let project = + match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) + .await + { + Ok(VirtualProject::Project(project)) => project, + Ok(VirtualProject::NonProject(_)) + | Err(WorkspaceError::MissingPyprojectToml | WorkspaceError::MissingProject(_)) => { + bail!("`uv upgrade` requires a project with a `[project]` table"); + } + Err(err) => return Err(err.into()), + }; + + if project.workspace().packages().len() != 1 { + bail!("`uv upgrade` does not support workspaces with multiple members yet"); + } + + let dependencies = project + .current_project() + .project() + .dependencies + .as_deref() + .unwrap_or_default(); + let mut matching = Vec::new(); + for dependency in dependencies { + let requirement = + Requirement::::from_str(dependency).with_context(|| { + format!("Failed to parse dependency `{dependency}` in `project.dependencies`") + })?; + if requirement.name == package { + matching.push(requirement); + } + } + + let requirement = match matching.as_slice() { + [] => bail!("Dependency `{package}` was not found in `project.dependencies`"), + [requirement] => requirement, + _ => bail!("Dependency `{package}` is declared multiple times in `project.dependencies`"), + }; + + if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { + bail!("Dependency `{package}` is a direct URL requirement and cannot be upgraded"); + } + + if &requirement.name == project.project_name() { + bail!("Dependency `{package}` refers to the current project and cannot be upgraded"); + } + + let sources = project + .current_project() + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .and_then(|sources| sources.inner().get(&package)) + .or_else(|| project.workspace().sources().get(&package)); + let extra = requirement.marker.top_level_extra_name(); + if sources.is_some_and(|sources| { + sources.iter().any(|source| { + source + .extra() + .is_none_or(|target| extra.as_deref() == Some(target)) + && source.group().is_none() + && !source.marker().is_disjoint(requirement.marker) + && !matches!(source, Source::Registry { .. }) + }) + }) { + bail!( + "Dependency `{package}` uses a non-registry source in `tool.uv.sources` and cannot be upgraded" + ); + } + + let relaxed_requirement = relax_requirement(requirement); + let Requirement { + name, + extras, + version_or_url, + marker, + origin, + } = relaxed_requirement; + let version_or_url = match version_or_url { + Some(VersionOrUrl::VersionSpecifier(specifiers)) => { + Some(VersionOrUrl::VersionSpecifier(specifiers)) + } + Some(VersionOrUrl::Url(_)) => { + bail!("Dependency `{package}` is a direct URL requirement and cannot be upgraded"); + } + None => None, + }; + let relaxed_requirement = Requirement:: { + name, + extras, + version_or_url, + marker, + origin, + }; + + let mut pyproject = PyProjectTomlMut::from_toml( + &project.current_project().pyproject_toml().raw, + DependencyTarget::PyProjectToml, + )?; + if pyproject + .replace_dependency(&relaxed_requirement, false)? + .is_none() + { + bail!("Dependency `{package}` was not found in `project.dependencies`"); + } + let pyproject = pyproject.to_string(); + let pyproject = PyProjectToml::from_toml( + &pyproject, + project.project_root().join("pyproject.toml").display(), + )?; + if pyproject + .project + .as_ref() + .is_some_and(|project| project.version.is_none()) + { + // TODO: Support dynamic project metadata by building the project before resolution. + bail!("`uv upgrade` does not support projects with dynamic versions yet"); + } + let metadata = ResolutionMetadata::parse_pyproject_toml(pyproject, None)?; + let metadata = Metadata::from_workspace( + metadata, + project.project_root(), + None, + &settings.index_locations, + settings.sources.clone(), + true, + workspace_cache, + client_builder.credentials_cache(), + ) + .await?; + + let groups = DependencyGroupsWithDefaults::none(); + let workspace_python = WorkspacePython::from_request( + None, + Some(project.workspace()), + &groups, + project_dir, + no_config, + ) + .await?; + let interpreter = ProjectInterpreter::discover( + project.workspace(), + &groups, + workspace_python, + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + false, + Some(false), + cache, + printer, + ) + .await? + .into_interpreter(); + + let state = UniversalState::default(); + let distribution_id = DisplaySafeUrl::from_file_path(project.project_root()) + .map_err(|()| anyhow!("Project root is not a valid file URL"))? + .distribution_id(); + state.index().distributions().done( + distribution_id, + Arc::new(MetadataResponse::Found(ArchiveMetadata::from(metadata))), + ); + + let refresh = Refresh::from(settings.upgrade.clone()); + let result = match Box::pin( + LockOperation::new( + LockMode::DryRun(&interpreter), + &settings, + &client_builder, + &state, + Box::new(DefaultResolveLogger), + &concurrency, + cache, + workspace_cache, + printer, + preview, + ) + .with_refresh(&refresh) + .execute(LockTarget::Workspace(project.workspace())), + ) + .await + { + Ok(result) => result, + Err(ProjectError::Operation(err)) => { + return diagnostics::OperationDiagnostic::with_system_certs( + client_builder.system_certs(), + ) + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())); + } + Err(err) => return Err(err.into()), + }; + + let event = match &result { + LockResult::Changed(previous, lock) => { + LockEvent::detect_changes(previous.as_ref(), lock, DryRun::Enabled) + .find(|event| event.package() == &package) + } + LockResult::Unchanged(_) => None, + }; + if let Some(event) = event { + writeln!(printer.stderr(), "{event}")?; + } else { + writeln!(printer.stderr(), "No version change for {package}")?; + } + + Ok(ExitStatus::Success) +} + +fn relax_requirement( + requirement: &Requirement, +) -> Requirement { + let mut relaxed = requirement.clone(); + let Some(VersionOrUrl::VersionSpecifier(specifiers)) = &requirement.version_or_url else { + return relaxed; + }; + + let specifiers = specifiers + .iter() + .filter_map(|specifier| match specifier.operator() { + Operator::GreaterThan + | Operator::GreaterThanEqual + | Operator::NotEqual + | Operator::NotEqualStar => Some(specifier.clone()), + Operator::TildeEqual => Some(VersionSpecifier::greater_than_equal_version( + specifier.version().clone(), + )), + Operator::Equal + | Operator::EqualStar + | Operator::ExactEqual + | Operator::LessThan + | Operator::LessThanEqual => None, + }) + .collect::(); + + relaxed.version_or_url = if specifiers.is_empty() { + None + } else { + Some(VersionOrUrl::VersionSpecifier(specifiers)) + }; + relaxed +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use uv_pep508::Requirement; + use uv_pypi_types::VerbatimParsedUrl; + + use super::relax_requirement; + + #[test] + fn relax_requirement_preserves_lower_bounds_and_exclusions() { + let requirement = Requirement::::from_str( + "Requests>=1,>1.5,!=2,!=2.1.*,==2.5,===2.6,<=3,<4", + ) + .expect("valid requirement"); + + let relaxed = relax_requirement(&requirement); + + assert_eq!(relaxed.to_string(), "requests>=1,>1.5,!=2,!=2.1.*"); + } + + #[test] + fn relax_requirement_converts_compatible_release_to_lower_bound() { + let requirement = Requirement::::from_str("requests~=2.32.1") + .expect("valid requirement"); + + let relaxed = relax_requirement(&requirement); + + assert_eq!(relaxed.to_string(), "requests>=2.32.1"); + } + + #[test] + fn relax_requirement_removes_blocking_only_constraints() { + for requirement in [ + "requests==2.32.1", + "requests===2.32.1", + "requests==2.32.*", + "requests<3", + "requests<=3", + ] { + let requirement = + Requirement::::from_str(requirement).expect("valid requirement"); + + let relaxed = relax_requirement(&requirement); + + assert_eq!(relaxed.to_string(), "requests"); + } + } + + #[test] + fn relax_requirement_preserves_requirement_metadata() { + let requirement = Requirement::::from_str( + "Requests_Plus[security,tests]~=2.32 ; python_version >= '3.12'", + ) + .expect("valid requirement"); + + let relaxed = relax_requirement(&requirement); + + assert_eq!( + relaxed.to_string(), + "requests-plus[security,tests]>=2.32 ; python_full_version >= '3.12'" + ); + } +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 887270c5b425d..80a919803afcc 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2331,6 +2331,34 @@ async fn run_project( )) .await } + ProjectCommand::Upgrade(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::UpgradeSettings::resolve(args, filesystem, environment); + show_settings!(args); + + // Initialize the cache. + let cache = cache + .init() + .await? + .with_refresh(Refresh::from(args.settings.upgrade.clone())); + + Box::pin(commands::upgrade( + project_dir, + args.package, + args.install_mirrors, + args.settings, + client_builder.subcommand(vec!["upgrade".to_owned()]), + globals.python_preference, + globals.python_downloads, + globals.concurrency, + no_config, + &cache, + workspace_cache, + printer, + globals.preview, + )) + .await + } ProjectCommand::Add(args) => { // Resolve the settings from the command-line arguments and workspace configuration. let mut args = settings::AddSettings::resolve(args, filesystem, environment); diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index f78417cb0890a..bbc86d2577e28 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -19,7 +19,8 @@ use uv_cli::{ PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs, PythonListFormat, PythonPinArgs, PythonUninstallArgs, PythonUpgradeArgs, RemoveArgs, RunArgs, SyncArgs, SyncFormat, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, - ToolUninstallArgs, TreeArgs, VenvArgs, VersionArgs, VersionBumpSpec, VersionFormat, + ToolUninstallArgs, TreeArgs, UpgradeArgs, VenvArgs, VersionArgs, VersionBumpSpec, + VersionFormat, }; use uv_cli::{ AuthorFrom, BuildArgs, CheckArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs, @@ -1925,6 +1926,41 @@ impl LockSettings { } } } + +/// The resolved settings to use for an `upgrade` invocation. +#[derive(Debug, Clone)] +pub(crate) struct UpgradeSettings { + pub(crate) package: PackageName, + pub(crate) install_mirrors: PythonInstallMirrors, + pub(crate) settings: ResolverSettings, +} + +impl UpgradeSettings { + /// Resolve the [`UpgradeSettings`] from the CLI and filesystem configuration. + pub(crate) fn resolve( + args: UpgradeArgs, + filesystem: Option, + environment: EnvironmentOptions, + ) -> Self { + let filesystem_install_mirrors = filesystem + .clone() + .map(|fs| fs.install_mirrors.clone()) + .unwrap_or_default(); + let package = args.package; + let mut settings = + ResolverSettings::combine(ResolverOptions::default(), filesystem, &environment); + settings.upgrade = Upgrade::package(package.clone()); + + Self { + package, + install_mirrors: environment + .install_mirrors + .combine(filesystem_install_mirrors), + settings, + } + } +} + /// The resolved settings to use for a `lock` invocation. #[derive(Debug, Clone)] pub(crate) struct MetadataSettings { @@ -4871,3 +4907,25 @@ fn parse_failure(name: &str, expected: &str) -> ! { eprintln!("error: invalid value for {name}, expected {expected}"); process::exit(1) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upgrade_settings_target_only_requested_package() -> anyhow::Result<()> { + let package = PackageName::from_str("anyio")?; + let settings = UpgradeSettings::resolve( + UpgradeArgs { + package: package.clone(), + }, + None, + EnvironmentOptions::new()?, + ); + let expected = FxHashSet::from_iter([package]); + + assert!(!settings.settings.upgrade.is_all()); + assert_eq!(settings.settings.upgrade.packages(), Some(&expected)); + Ok(()) + } +} diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 27bf679237cac..a75fbcdfa2819 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -149,6 +149,8 @@ mod tool_upgrade; #[cfg(all(feature = "test-python", feature = "test-pypi"))] mod tree; +mod upgrade; + #[cfg(feature = "test-python")] mod venv; diff --git a/crates/uv/tests/it/upgrade.rs b/crates/uv/tests/it/upgrade.rs new file mode 100644 index 0000000000000..59d77b5d8e9e3 --- /dev/null +++ b/crates/uv/tests/it/upgrade.rs @@ -0,0 +1,827 @@ +use anyhow::{Result, anyhow}; +use assert_fs::prelude::*; +use insta::allow_duplicates; +use url::Url; + +use uv_static::EnvVars; +use uv_test::{TestContext, uv_snapshot}; + +fn assert_project_unchanged(context: &TestContext, expected: &str) -> Result<()> { + assert_eq!( + fs_err::read_to_string(context.temp_dir.child("pyproject.toml"))?, + expected + ); + assert!(!context.temp_dir.child("uv.lock").exists()); + assert!(!context.temp_dir.child(".venv").exists()); + Ok(()) +} + +#[test] +fn upgrade_help() { + let context = uv_test::test_context_with_versions!(&[]); + + uv_snapshot!( + context.filters(), + context.upgrade().arg("--help"), + @r#" + success: true + exit_code: 0 + ----- stdout ----- + Upgrade a dependency in the project + + Usage: uv upgrade [OPTIONS] + + Arguments: + The package to upgrade + + Cache options: + -n, --no-cache Avoid reading from or writing to the cache, instead using a temporary + directory for the duration of the operation [env: UV_NO_CACHE=] + --cache-dir [CACHE_DIR] Path to the cache directory [env: UV_CACHE_DIR=] + + Python options: + --managed-python Require use of uv-managed Python versions [env: UV_MANAGED_PYTHON=] + --no-managed-python Disable use of uv-managed Python versions [env: UV_NO_MANAGED_PYTHON=] + --no-python-downloads Disable automatic downloads of Python. [env: + "UV_PYTHON_DOWNLOADS=never"] + + Global options: + -q, --quiet... + Use quiet output + -v, --verbose... + Use verbose output + --color + Control the use of color in output [possible values: auto, always, never] + --system-certs + Whether to load TLS certificates from the platform's native certificate store [env: + UV_SYSTEM_CERTS=] + --offline + Disable network access [env: UV_OFFLINE=] + --allow-insecure-host + Allow insecure connections to a host [env: UV_INSECURE_HOST=] + --no-progress + Hide all progress outputs [env: UV_NO_PROGRESS=] + --directory + Change to the given directory prior to running the command [env: UV_WORKING_DIR=] + --project + Discover a project in the given directory [env: UV_PROJECT=] + --config-file + The path to a `uv.toml` file to use for configuration [env: UV_CONFIG_FILE=] + --no-config + Avoid discovering configuration files (`pyproject.toml`, `uv.toml`) [env: UV_NO_CONFIG=] + -h, --help + Display the concise help for this command + + ----- stderr ----- + "# + ); +} + +#[test] +fn upgrade_selects_normalized_production_dependency() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let pyproject_toml = r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["AnyIO>=2,<3,!=2.1 ; python_version >= '3.12'"] + + [tool.uv] + exclude-newer = "2024-03-25T00:00:00Z" + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("anyio"), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 4 packages in [TIME] + Add anyio v4.3.0 + " + ); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_resolves_selected_dependency_without_mutation() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let initial_pyproject_toml = r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio<=2", "idna"] + + [tool.uv] + exclude-newer = "2021-01-01T00:00:00Z" + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(initial_pyproject_toml)?; + + uv_snapshot!( + context.filters(), + context.lock().env_remove(EnvVars::UV_EXCLUDE_NEWER), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + let pyproject_toml = + initial_pyproject_toml.replace("2021-01-01T00:00:00Z", "2024-03-25T00:00:00Z"); + context + .temp_dir + .child("pyproject.toml") + .write_str(&pyproject_toml)?; + fs_err::remove_dir_all(&context.venv)?; + + let pyproject = fs_err::read(context.temp_dir.child("pyproject.toml"))?; + let lock = fs_err::read(context.temp_dir.child("uv.lock"))?; + + uv_snapshot!( + context.filters(), + context + .upgrade() + .arg("anyio") + .env_remove(EnvVars::UV_EXCLUDE_NEWER), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolving despite existing lockfile due to change of exclude newer timestamp from `2021-01-01T00:00:00Z` to `2024-03-25T00:00:00Z` + Resolved 4 packages in [TIME] + Update anyio v2.0.0 -> v4.3.0 + "); + + assert_eq!( + fs_err::read(context.temp_dir.child("pyproject.toml"))?, + pyproject + ); + assert_eq!(fs_err::read(context.temp_dir.child("uv.lock"))?, lock); + let lock_contents = context.read("uv.lock"); + assert!( + lock_contents.contains("name = \"idna\"\nversion = \"2.10\""), + "{lock_contents}" + ); + assert!(!context.temp_dir.child(".venv").exists()); + Ok(()) +} + +#[test] +fn upgrade_reports_no_solution_without_mutation() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let pyproject_toml = r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio<=2", "idna==9999"] + + [tool.uv] + exclude-newer = "2024-03-25T00:00:00Z" + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!(context.filters(), context.upgrade().arg("anyio"), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + × No solution found when resolving dependencies: + ╰─▶ Because there is no version of idna==9999 and your project depends on idna==9999, we can conclude that your project's requirements are unsatisfiable. + "); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_reports_no_version_change_without_mutation() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let pyproject_toml = r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio"] + + [tool.uv] + exclude-newer = "2024-03-25T00:00:00Z" + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + fs_err::remove_dir_all(&context.venv)?; + + let pyproject = fs_err::read(context.temp_dir.child("pyproject.toml"))?; + let lock = fs_err::read(context.temp_dir.child("uv.lock"))?; + + uv_snapshot!(context.filters(), context.upgrade().arg("anyio"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 4 packages in [TIME] + No version change for anyio + "); + + assert_eq!( + fs_err::read(context.temp_dir.child("pyproject.toml"))?, + pyproject + ); + assert_eq!(fs_err::read(context.temp_dir.child("uv.lock"))?, lock); + assert!(!context.temp_dir.child(".venv").exists()); + Ok(()) +} + +#[test] +fn upgrade_rejects_dynamic_project_version() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let pyproject_toml = r#" + [project] + name = "project" + dynamic = ["version"] + requires-python = ">=3.12" + dependencies = ["anyio<=2"] + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!(context.filters(), context.upgrade().arg("anyio"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `uv upgrade` does not support projects with dynamic versions yet + "); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_requires_production_dependency() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + let pyproject_toml = r#" + [project] + name = "example" + version = "0.1.0" + dependencies = ["anyio"] + + [project.optional-dependencies] + test = ["requests"] + + [dependency-groups] + dev = ["httpx"] + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency `requests` was not found in `project.dependencies` + " + ); + + uv_snapshot!( + context.filters(), + context.upgrade().arg("httpx"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency `httpx` was not found in `project.dependencies` + " + ); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_rejects_duplicate_production_dependencies() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + let pyproject_toml = r#" + [project] + name = "example" + version = "0.1.0" + dependencies = [ + "Requests>=2", + "requests<3", + ] + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency `requests` is declared multiple times in `project.dependencies` + " + ); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_rejects_direct_url_requirement() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + let pyproject_toml = r#" + [project] + name = "example" + version = "0.1.0" + dependencies = [ + "requests @ https://example.com/requests-2.32.0-py3-none-any.whl", + ] + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency `requests` is a direct URL requirement and cannot be upgraded + " + ); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_rejects_self_dependency() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + let pyproject_toml = r#" + [project] + name = "project" + version = "0.1.0" + dependencies = ["project[foo]>=0.1"] + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + + uv_snapshot!(context.filters(), context.upgrade().arg("project"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency `project` refers to the current project and cannot be upgraded + "); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_rejects_non_registry_sources() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + + allow_duplicates! { + for source in [ + r#"{ git = "https://github.com/psf/requests" }"#, + r#"{ url = "https://example.com/requests-2.32.0-py3-none-any.whl" }"#, + r#"{ path = "vendor/requests" }"#, + r"{ workspace = true }", + ] { + let pyproject_toml = format!( + r#" + [project] + name = "example" + version = "0.1.0" + dependencies = ["requests>=2"] + + [tool.uv.sources] + requests = {source} + "# + ); + context + .temp_dir + .child("pyproject.toml") + .write_str(&pyproject_toml)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency `requests` uses a non-registry source in `tool.uv.sources` and cannot be upgraded + " + ); + + assert_project_unchanged(&context, &pyproject_toml)?; + } + + Ok::<(), anyhow::Error>(()) + }?; + + Ok(()) +} + +#[test] +fn upgrade_rejects_non_registry_source_for_top_level_extra() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + let pyproject_toml = r#" + [project] + name = "example" + version = "0.1.0" + dependencies = ["requests>=2 ; extra == 'gpu'"] + + [tool.uv.sources] + requests = { git = "https://github.com/psf/requests", extra = "gpu" } + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency `requests` uses a non-registry source in `tool.uv.sources` and cannot be upgraded + " + ); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_allows_registry_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let empty_index = context.temp_dir.child("empty-index"); + empty_index.create_dir_all()?; + let empty_index = Url::from_directory_path(empty_index.path()) + .map_err(|()| anyhow!("Failed to create empty index URL"))?; + let pyproject_toml = format!( + r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["idna>=2,<3"] + + [tool.uv] + exclude-newer = "2024-03-25T00:00:00Z" + + [tool.uv.sources] + idna = {{ index = "pypi" }} + + [[tool.uv.index]] + name = "pypi" + url = "https://pypi.org/simple" + explicit = true + + [[tool.uv.index]] + name = "empty" + url = "{empty_index}" + default = true + "# + ); + context + .temp_dir + .child("pyproject.toml") + .write_str(&pyproject_toml)?; + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("idna"), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + Add idna v3.6 + " + ); + + assert_project_unchanged(&context, &pyproject_toml) +} + +#[test] +fn upgrade_ignores_inapplicable_non_registry_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let pyproject_toml = r#" + [project] + name = "example" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio>=2,<3 ; python_version >= '3.12'"] + + [tool.uv] + exclude-newer = "2024-03-25T00:00:00Z" + + [tool.uv.sources] + anyio = { git = "https://github.com/agronholm/anyio", marker = "python_version < '3.12'" } + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("anyio"), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 4 packages in [TIME] + Add anyio v4.3.0 + " + ); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_rejects_workspace_root_non_registry_source() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + let workspace_pyproject_toml = r#" + [tool.uv.workspace] + members = ["project"] + + [tool.uv.sources] + requests = { path = "vendor/requests" } + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(workspace_pyproject_toml)?; + + let project = context.temp_dir.child("project"); + project.create_dir_all()?; + let project_pyproject_toml = r#" + [project] + name = "example" + version = "0.1.0" + dependencies = ["requests>=2"] + "#; + project + .child("pyproject.toml") + .write_str(project_pyproject_toml)?; + + uv_snapshot!( + context.filters(), + context + .upgrade() + .current_dir(&project) + .arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency `requests` uses a non-registry source in `tool.uv.sources` and cannot be upgraded + " + ); + + assert_eq!( + fs_err::read_to_string(context.temp_dir.child("pyproject.toml"))?, + workspace_pyproject_toml + ); + assert_eq!( + fs_err::read_to_string(project.child("pyproject.toml"))?, + project_pyproject_toml + ); + assert!(!context.temp_dir.child("uv.lock").exists()); + assert!(!context.temp_dir.child(".venv").exists()); + Ok(()) +} + +#[test] +fn upgrade_resolves_nested_workspace_member_without_mutation() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let workspace_pyproject_toml = r#" + [tool.uv.workspace] + members = ["project"] + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(workspace_pyproject_toml)?; + + let project = context.temp_dir.child("project"); + project.create_dir_all()?; + let project_pyproject_toml = r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio<=2"] + + [tool.uv] + exclude-newer = "2024-03-25T00:00:00Z" + "#; + project + .child("pyproject.toml") + .write_str(project_pyproject_toml)?; + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!( + context.filters(), + context.upgrade().current_dir(&project).arg("anyio"), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 4 packages in [TIME] + Add anyio v4.3.0 + " + ); + + assert_eq!( + fs_err::read_to_string(context.temp_dir.child("pyproject.toml"))?, + workspace_pyproject_toml + ); + assert_eq!( + fs_err::read_to_string(project.child("pyproject.toml"))?, + project_pyproject_toml + ); + assert!(!context.temp_dir.child("uv.lock").exists()); + assert!(!context.temp_dir.child(".venv").exists()); + assert!(!project.child("uv.lock").exists()); + assert!(!project.child(".venv").exists()); + Ok(()) +} + +#[test] +fn upgrade_requires_current_project() { + let context = uv_test::test_context_with_versions!(&[]); + + uv_snapshot!( + context.filters(), + context.upgrade().arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `uv upgrade` requires a project with a `[project]` table + " + ); + + assert!(!context.temp_dir.child("uv.lock").exists()); + assert!(!context.temp_dir.child(".venv").exists()); +} + +#[test] +fn upgrade_rejects_virtual_workspace_root() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + let pyproject_toml = r" + [tool.uv.workspace] + members = [] + "; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `uv upgrade` requires a project with a `[project]` table + " + ); + + assert_project_unchanged(&context, pyproject_toml) +} + +#[test] +fn upgrade_rejects_multi_member_workspace() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + let pyproject_toml = r#" + [project] + name = "example" + version = "0.1.0" + dependencies = ["requests>=2"] + + [tool.uv.workspace] + members = ["member"] + "#; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml)?; + context.temp_dir.child("member").create_dir_all()?; + context + .temp_dir + .child("member") + .child("pyproject.toml") + .write_str( + r#" + [project] + name = "member" + version = "0.1.0" + "#, + )?; + + uv_snapshot!( + context.filters(), + context.upgrade().arg("requests"), + @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `uv upgrade` does not support workspaces with multiple members yet + " + ); + + assert_project_unchanged(&context, pyproject_toml) +}