diff --git a/Cargo.lock b/Cargo.lock index 49d68041a087..2b83f1d9388b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4681,6 +4681,7 @@ dependencies = [ "tokio", "tokio-util", "toml", + "toml_edit", "tracing", "url", "uv-cache", diff --git a/crates/uv-distribution/Cargo.toml b/crates/uv-distribution/Cargo.toml index 998cf1ec9e94..810e35546ed0 100644 --- a/crates/uv-distribution/Cargo.toml +++ b/crates/uv-distribution/Cargo.toml @@ -48,6 +48,7 @@ thiserror = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat"] } toml = { workspace = true } +toml_edit = { workspace = true } tracing = { workspace = true } url = { workspace = true } zip = { workspace = true } diff --git a/crates/uv-distribution/src/lib.rs b/crates/uv-distribution/src/lib.rs index 2a5649704c10..15146a99ea31 100644 --- a/crates/uv-distribution/src/lib.rs +++ b/crates/uv-distribution/src/lib.rs @@ -14,6 +14,7 @@ mod index; mod locks; mod metadata; pub mod pyproject; +pub mod pyproject_mut; mod reporter; mod source; mod workspace; diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 272bd1ed189d..2ae3904497e5 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -156,7 +156,7 @@ mod test { use crate::{ProjectWorkspace, RequiresDist}; async fn requires_dist_from_pyproject_toml(contents: &str) -> anyhow::Result { - let pyproject_toml: PyProjectToml = toml::from_str(contents)?; + let pyproject_toml = PyProjectToml::from_string(contents.to_string())?; let path = Path::new("pyproject.toml"); let project_workspace = ProjectWorkspace::from_project( path, diff --git a/crates/uv-distribution/src/pyproject.rs b/crates/uv-distribution/src/pyproject.rs index 25976e4fa970..984481179ee0 100644 --- a/crates/uv-distribution/src/pyproject.rs +++ b/crates/uv-distribution/src/pyproject.rs @@ -18,15 +18,35 @@ use pypi_types::VerbatimParsedUrl; use uv_normalize::{ExtraName, PackageName}; /// A `pyproject.toml` as specified in PEP 517. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "kebab-case")] pub struct PyProjectToml { /// PEP 621-compliant project metadata. pub project: Option, /// Tool-specific metadata. pub tool: Option, + /// The raw unserialized document. + #[serde(skip)] + pub(crate) raw: String, +} + +impl PyProjectToml { + /// Parse a `PyProjectToml` from a raw TOML string. + pub fn from_string(raw: String) -> Result { + let pyproject = toml::from_str(&raw)?; + Ok(PyProjectToml { raw, ..pyproject }) + } } +// Ignore raw document in comparison. +impl PartialEq for PyProjectToml { + fn eq(&self, other: &Self) -> bool { + self.project.eq(&other.project) && self.tool.eq(&other.tool) + } +} + +impl Eq for PyProjectToml {} + /// PEP 621 project metadata (`project`). /// /// See . diff --git a/crates/uv-distribution/src/pyproject_mut.rs b/crates/uv-distribution/src/pyproject_mut.rs new file mode 100644 index 000000000000..4e9215629d16 --- /dev/null +++ b/crates/uv-distribution/src/pyproject_mut.rs @@ -0,0 +1,161 @@ +use std::fmt; +use std::str::FromStr; + +use thiserror::Error; +use toml_edit::{Array, DocumentMut, Item, RawString, TomlError, Value}; + +use pep508_rs::{PackageName, Requirement}; +use pypi_types::VerbatimParsedUrl; + +use crate::pyproject::PyProjectToml; + +/// Raw and mutable representation of a `pyproject.toml`. +/// +/// This is useful for operations that require editing an existing `pyproject.toml` while +/// preserving comments and other structure, such as `uv add` and `uv remove`. +pub struct PyProjectTomlMut { + doc: DocumentMut, +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("Failed to parse `pyproject.toml`")] + Parse(#[from] Box), + #[error("Dependencies in `pyproject.toml` are malformed")] + MalformedDependencies, +} + +impl PyProjectTomlMut { + /// Initialize a `PyProjectTomlMut` from a `PyProjectToml`. + pub fn from_toml(pyproject: &PyProjectToml) -> Result { + Ok(Self { + doc: pyproject.raw.parse().map_err(Box::new)?, + }) + } + + /// Adds a dependency. + pub fn add_dependency(&mut self, req: &Requirement) -> Result<(), Error> { + let deps = &mut self.doc["project"]["dependencies"]; + if deps.is_none() { + *deps = Item::Value(Value::Array(Array::new())); + } + let deps = deps.as_array_mut().ok_or(Error::MalformedDependencies)?; + + // Try to find matching dependencies. + let mut to_replace = Vec::new(); + for (i, dep) in deps.iter().enumerate() { + if dep + .as_str() + .and_then(try_parse_requirement) + .filter(|dep| dep.name == req.name) + .is_some() + { + to_replace.push(i); + } + } + + if to_replace.is_empty() { + deps.push(req.to_string()); + } else { + // Replace the first occurrence of the dependency and remove the rest. + deps.replace(to_replace[0], req.to_string()); + for &i in to_replace[1..].iter().rev() { + deps.remove(i); + } + } + + reformat_array_multiline(deps); + Ok(()) + } + + /// Removes all occurrences of dependencies with the given name. + pub fn remove_dependency(&mut self, req: &PackageName) -> Result, Error> { + let deps = &mut self.doc["project"]["dependencies"]; + if deps.is_none() { + return Ok(Vec::new()); + } + + let deps = deps.as_array_mut().ok_or(Error::MalformedDependencies)?; + + // Try to find matching dependencies. + let mut to_remove = Vec::new(); + for (i, dep) in deps.iter().enumerate() { + if dep + .as_str() + .and_then(try_parse_requirement) + .filter(|dep| dep.name == *req) + .is_some() + { + to_remove.push(i); + } + } + + let removed = to_remove + .into_iter() + .rev() // Reverse to preserve indices as we remove them. + .filter_map(|i| { + deps.remove(i) + .as_str() + .and_then(|req| Requirement::from_str(req).ok()) + }) + .collect::>(); + + if !removed.is_empty() { + reformat_array_multiline(deps); + } + + Ok(removed) + } +} + +impl fmt::Display for PyProjectTomlMut { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.doc.fmt(f) + } +} + +fn try_parse_requirement(req: &str) -> Option> { + Requirement::from_str(req).ok() +} + +/// Reformats a TOML array to multi line while trying to preserve all comments +/// and move them around. This also formats the array to have a trailing comma. +fn reformat_array_multiline(deps: &mut Array) { + fn find_comments(s: Option<&RawString>) -> impl Iterator { + s.and_then(|x| x.as_str()) + .unwrap_or("") + .lines() + .filter_map(|line| { + let line = line.trim(); + line.starts_with('#').then_some(line) + }) + } + + for item in deps.iter_mut() { + let decor = item.decor_mut(); + let mut prefix = String::new(); + for comment in find_comments(decor.prefix()).chain(find_comments(decor.suffix())) { + prefix.push_str("\n "); + prefix.push_str(comment); + } + prefix.push_str("\n "); + decor.set_prefix(prefix); + decor.set_suffix(""); + } + + deps.set_trailing(&{ + let mut comments = find_comments(Some(deps.trailing())).peekable(); + let mut rv = String::new(); + if comments.peek().is_some() { + for comment in comments { + rv.push_str("\n "); + rv.push_str(comment); + } + } + if !rv.is_empty() || !deps.is_empty() { + rv.push('\n'); + } + rv + }); + deps.set_trailing_comma(true); +} diff --git a/crates/uv-distribution/src/workspace.rs b/crates/uv-distribution/src/workspace.rs index 5689f5bade94..0a09c2fa84c8 100644 --- a/crates/uv-distribution/src/workspace.rs +++ b/crates/uv-distribution/src/workspace.rs @@ -75,7 +75,7 @@ impl Workspace { let pyproject_path = project_root.join("pyproject.toml"); let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; - let pyproject_toml: PyProjectToml = toml::from_str(&contents) + let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; let project_path = absolutize_path(project_root) @@ -242,7 +242,7 @@ impl Workspace { if let Some(project) = &workspace_pyproject_toml.project { let pyproject_path = workspace_root.join("pyproject.toml"); let contents = fs_err::read_to_string(&pyproject_path)?; - let pyproject_toml = toml::from_str(&contents) + let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; debug!( @@ -297,7 +297,7 @@ impl Workspace { // Read the member `pyproject.toml`. let pyproject_path = member_root.join("pyproject.toml"); let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; - let pyproject_toml: PyProjectToml = toml::from_str(&contents) + let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; // Extract the package name. @@ -490,7 +490,7 @@ impl ProjectWorkspace { // Read the current `pyproject.toml`. let pyproject_path = project_root.join("pyproject.toml"); let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; - let pyproject_toml: PyProjectToml = toml::from_str(&contents) + let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; // It must have a `[project]` table. @@ -514,7 +514,7 @@ impl ProjectWorkspace { // No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`. return Ok(None); }; - let pyproject_toml: PyProjectToml = toml::from_str(&contents) + let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; // Extract the `[project]` metadata. @@ -656,7 +656,7 @@ async fn find_workspace( // Read the `pyproject.toml`. let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; - let pyproject_toml: PyProjectToml = toml::from_str(&contents) + let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; return if let Some(workspace) = pyproject_toml diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index 9751f928e02b..6c2295f78651 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -153,6 +153,12 @@ pub(crate) enum Commands { /// Resolve the project requirements into a lockfile. #[clap(hide = true)] Lock(LockArgs), + /// Add one or more packages to the project requirements. + #[clap(hide = true)] + Add(AddArgs), + /// Remove one or more packages from the project requirements. + #[clap(hide = true)] + Remove(RemoveArgs), /// Display uv's version Version { #[arg(long, value_enum, default_value = "text")] @@ -1922,16 +1928,48 @@ pub(crate) struct LockArgs { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] -struct AddArgs { - /// The name of the package to add (e.g., `Django==4.2.6`). - name: String, +pub(crate) struct AddArgs { + /// The packages to remove, as PEP 508 requirements (e.g., `flask==2.2.3`). + #[arg(required = true)] + pub(crate) requirements: Vec, + + /// The Python interpreter into which packages should be installed. + /// + /// By default, `uv` installs into the virtual environment in the current working directory or + /// any parent directory. The `--python` option allows you to specify a different interpreter, + /// which is intended for use in continuous integration (CI) environments or other automated + /// workflows. + /// + /// Supported formats: + /// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or + /// `python3.10` on Linux and macOS. + /// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`. + /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. + #[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)] + pub(crate) python: Option, } #[derive(Args)] #[allow(clippy::struct_excessive_bools)] -struct RemoveArgs { - /// The name of the package to remove (e.g., `Django`). - name: PackageName, +pub(crate) struct RemoveArgs { + /// The names of the packages to remove (e.g., `flask`). + #[arg(required = true)] + pub(crate) requirements: Vec, + + /// The Python interpreter into which packages should be installed. + /// + /// By default, `uv` installs into the virtual environment in the current working directory or + /// any parent directory. The `--python` option allows you to specify a different interpreter, + /// which is intended for use in continuous integration (CI) environments or other automated + /// workflows. + /// + /// Supported formats: + /// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or + /// `python3.10` on Linux and macOS. + /// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`. + /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. + #[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)] + pub(crate) python: Option, } #[derive(Args)] diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index f83605e32866..bd6dd9965ffd 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -16,7 +16,9 @@ pub(crate) use pip::list::pip_list; pub(crate) use pip::show::pip_show; pub(crate) use pip::sync::pip_sync; pub(crate) use pip::uninstall::pip_uninstall; +pub(crate) use project::add::add; pub(crate) use project::lock::lock; +pub(crate) use project::remove::remove; pub(crate) use project::run::run; pub(crate) use project::sync::sync; #[cfg(feature = "self-update")] diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs new file mode 100644 index 000000000000..85231f04938d --- /dev/null +++ b/crates/uv/src/commands/project/add.rs @@ -0,0 +1,97 @@ +use anyhow::Result; +use std::str::FromStr; +use uv_distribution::pyproject_mut::PyProjectTomlMut; + +use distribution_types::IndexLocations; +use pep508_rs::Requirement; +use uv_cache::Cache; +use uv_configuration::{ExtrasSpecification, PreviewMode, Upgrade}; +use uv_distribution::ProjectWorkspace; +use uv_warnings::warn_user; + +use crate::commands::{project, ExitStatus}; +use crate::printer::Printer; + +/// Add one or more packages to the project requirements. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn add( + requirements: Vec, + python: Option, + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv add` is experimental and may change without warning."); + } + + // Find the project requirements. + let project = ProjectWorkspace::discover(&std::env::current_dir()?, None).await?; + + let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; + for req in requirements { + let req = Requirement::from_str(&req)?; + pyproject.add_dependency(&req)?; + } + + // Save the modified `pyproject.toml`. + fs_err::write( + project.current_project().root().join("pyproject.toml"), + pyproject.to_string(), + )?; + + // Discover or create the virtual environment. + let venv = project::init_environment( + project.workspace(), + python.as_deref(), + preview, + cache, + printer, + )?; + + let index_locations = IndexLocations::default(); + let upgrade = Upgrade::default(); + let exclude_newer = None; + + // Lock and sync the environment. + let root_project_name = project + .current_project() + .pyproject_toml() + .project + .as_ref() + .map(|project| project.name.clone()); + + let lock = project::lock::do_lock( + root_project_name, + project.workspace(), + venv.interpreter(), + &index_locations, + upgrade, + exclude_newer, + preview, + cache, + printer, + ) + .await?; + + // Perform a full sync, because we don't know what exactly is affected by the removal. + // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? + let extras = ExtrasSpecification::All; + let dev = true; + + project::sync::do_sync( + project.project_name(), + project.workspace().root(), + &venv, + &lock, + &index_locations, + extras, + dev, + preview, + cache, + printer, + ) + .await?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index da15801eeb15..7df1ceeb1baf 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -28,7 +28,9 @@ use uv_warnings::warn_user; use crate::commands::pip; use crate::printer::Printer; +pub(crate) mod add; pub(crate) mod lock; +pub(crate) mod remove; pub(crate) mod run; pub(crate) mod sync; diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs new file mode 100644 index 000000000000..4b016fc0d3bb --- /dev/null +++ b/crates/uv/src/commands/project/remove.rs @@ -0,0 +1,100 @@ +use anyhow::Result; +use pep508_rs::PackageName; +use uv_distribution::pyproject_mut::PyProjectTomlMut; + +use distribution_types::IndexLocations; +use uv_cache::Cache; +use uv_configuration::{ExtrasSpecification, PreviewMode, Upgrade}; +use uv_distribution::ProjectWorkspace; +use uv_warnings::warn_user; + +use crate::commands::{project, ExitStatus}; +use crate::printer::Printer; + +/// Remove one or more packages from the project requirements. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn remove( + requirements: Vec, + python: Option, + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv remove` is experimental and may change without warning."); + } + + // Find the project requirements. + let project = ProjectWorkspace::discover(&std::env::current_dir()?, None).await?; + + let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; + for req in requirements { + if pyproject.remove_dependency(&req)?.is_empty() { + anyhow::bail!( + "The dependency `{}` could not be found in `dependencies`", + req + ); + } + } + + // Save the modified `pyproject.toml`. + fs_err::write( + project.current_project().root().join("pyproject.toml"), + pyproject.to_string(), + )?; + + // Discover or create the virtual environment. + let venv = project::init_environment( + project.workspace(), + python.as_deref(), + preview, + cache, + printer, + )?; + + let index_locations = IndexLocations::default(); + let upgrade = Upgrade::None; + let exclude_newer = None; + + // Lock and sync the environment. + let root_project_name = project + .current_project() + .pyproject_toml() + .project + .as_ref() + .map(|project| project.name.clone()); + + let lock = project::lock::do_lock( + root_project_name, + project.workspace(), + venv.interpreter(), + &index_locations, + upgrade, + exclude_newer, + preview, + cache, + printer, + ) + .await?; + + // Perform a full sync, because we don't know what exactly is affected by the removal. + // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? + let extras = ExtrasSpecification::All; + let dev = true; + + project::sync::do_sync( + project.project_name(), + project.workspace().root(), + &venv, + &lock, + &index_locations, + extras, + dev, + preview, + cache, + printer, + ) + .await?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index c9134016810f..2bc5ad812da8 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -637,6 +637,38 @@ async fn run() -> Result { ) .await } + Commands::Add(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::AddSettings::resolve(args, workspace); + + // Initialize the cache. + let cache = cache.init()?; + + commands::add( + args.requirements, + args.python, + globals.preview, + &cache, + printer, + ) + .await + } + Commands::Remove(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::RemoveSettings::resolve(args, workspace); + + // Initialize the cache. + let cache = cache.init()?; + + commands::remove( + args.requirements, + args.python, + globals.preview, + &cache, + printer, + ) + .await + } #[cfg(feature = "self-update")] Commands::Self_(SelfNamespace { command: SelfCommand::Update, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 9650f44088f2..9b6f6a00fc0f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -21,9 +21,9 @@ use uv_toolchain::{Prefix, PythonVersion, Target}; use uv_workspace::{Combine, PipOptions, Workspace}; use crate::cli::{ - ColorChoice, GlobalArgs, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, - PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipUninstallArgs, RunArgs, SyncArgs, - ToolRunArgs, ToolchainInstallArgs, ToolchainListArgs, VenvArgs, + AddArgs, ColorChoice, GlobalArgs, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, + PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipUninstallArgs, RemoveArgs, RunArgs, + SyncArgs, ToolRunArgs, ToolchainInstallArgs, ToolchainListArgs, VenvArgs, }; use crate::commands::ListFormat; @@ -387,6 +387,54 @@ impl LockSettings { } } +/// The resolved settings to use for a `add` invocation. +#[allow(clippy::struct_excessive_bools, dead_code)] +#[derive(Debug, Clone)] +pub(crate) struct AddSettings { + pub(crate) requirements: Vec, + pub(crate) python: Option, +} + +impl AddSettings { + /// Resolve the [`AddSettings`] from the CLI and workspace configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: AddArgs, _workspace: Option) -> Self { + let AddArgs { + requirements, + python, + } = args; + + Self { + requirements, + python, + } + } +} + +/// The resolved settings to use for a `remove` invocation. +#[allow(clippy::struct_excessive_bools, dead_code)] +#[derive(Debug, Clone)] +pub(crate) struct RemoveSettings { + pub(crate) requirements: Vec, + pub(crate) python: Option, +} + +impl RemoveSettings { + /// Resolve the [`RemoveSettings`] from the CLI and workspace configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: RemoveArgs, _workspace: Option) -> Self { + let RemoveArgs { + requirements, + python, + } = args; + + Self { + requirements, + python, + } + } +} + /// The resolved settings to use for a `pip compile` invocation. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index a5944523e5ef..1cd759bb364a 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -317,6 +317,48 @@ impl TestContext { command } + /// Create a `uv add` command for the given requirements. + pub fn add(&self, reqs: &[&str]) -> std::process::Command { + let mut command = std::process::Command::new(get_bin()); + command + .arg("add") + .args(reqs) + .arg("--cache-dir") + .arg(self.cache_dir.path()) + .env("VIRTUAL_ENV", self.venv.as_os_str()) + .env("UV_NO_WRAP", "1") + .current_dir(&self.temp_dir); + + if cfg!(all(windows, debug_assertions)) { + // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the + // default windows stack of 1MB + command.env("UV_STACK_SIZE", (4 * 1024 * 1024).to_string()); + } + + command + } + + /// Create a `uv remove` command for the given requirements. + pub fn remove(&self, reqs: &[&str]) -> std::process::Command { + let mut command = std::process::Command::new(get_bin()); + command + .arg("remove") + .args(reqs) + .arg("--cache-dir") + .arg(self.cache_dir.path()) + .env("VIRTUAL_ENV", self.venv.as_os_str()) + .env("UV_NO_WRAP", "1") + .current_dir(&self.temp_dir); + + if cfg!(all(windows, debug_assertions)) { + // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the + // default windows stack of 1MB + command.env("UV_STACK_SIZE", (4 * 1024 * 1024).to_string()); + } + + command + } + pub fn interpreter(&self) -> PathBuf { venv_to_interpreter(&self.venv) } diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs new file mode 100644 index 000000000000..4bbd5d2cf555 --- /dev/null +++ b/crates/uv/tests/edit.rs @@ -0,0 +1,716 @@ +#![cfg(all(feature = "python", feature = "pypi"))] + +use anyhow::Result; +use assert_fs::prelude::*; +use indoc::indoc; +use insta::assert_snapshot; + +use common::{uv_snapshot, TestContext}; + +mod common; + +/// Add a PyPI requirement. +#[test] +fn add_registry() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add(&["anyio==3.7.0"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Downloaded 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.7 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [ + "anyio==3.7.0", + ] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "anyio" + version = "3.7.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737 } + wheels = [{ url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0", size = 80873 }] + + [[distribution.dependencies]] + name = "exceptiongroup" + version = "1.2.1" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.11'" + + [[distribution.dependencies]] + name = "idna" + version = "3.7" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "sniffio" + version = "1.3.1" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "typing-extensions" + version = "4.12.2" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.8'" + + [[distribution]] + name = "exceptiongroup" + version = "1.2.1" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/a0/65/d66b7fbaef021b3c954b3bbb196d21d8a4b97918ea524f82cfae474215af/exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16", size = 28717 } + wheels = [{ url = "https://files.pythonhosted.org/packages/01/90/79fe92dd413a9cab314ef5c591b5aa9b9ba787ae4cadab75055b0ae00b33/exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad", size = 16458 }] + + [[distribution]] + name = "idna" + version = "3.7" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/21/ed/f86a79a07470cb07819390452f178b3bef1d375f2ec021ecfc709fc7cf07/idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", size = 189575 } + wheels = [{ url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0", size = 66836 }] + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + + [[distribution.dependencies]] + name = "anyio" + version = "3.7.0" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "sniffio" + version = "1.3.1" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } + wheels = [{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }] + + [[distribution]] + name = "typing-extensions" + version = "4.12.2" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } + wheels = [{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }] + "### + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Audited 4 packages in [TIME] + "###); + + Ok(()) +} + +/// Add a Git requirement. +#[test] +fn add_git() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + "#})?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning. + Resolved 6 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Downloaded 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + uv_snapshot!(context.filters(), context.add(&["uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage@0.0.1"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning. + Resolved 7 packages in [TIME] + Downloaded 2 packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 2 packages in [TIME] + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@0dacfd662c64cb4ceb16e6cf65a157a8b715b979?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio==3.7.0", + "uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage@0.0.1", + ] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "anyio" + version = "3.7.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737 } + wheels = [{ url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0", size = 80873 }] + + [[distribution.dependencies]] + name = "exceptiongroup" + version = "1.2.0" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.11'" + + [[distribution.dependencies]] + name = "idna" + version = "3.6" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "sniffio" + version = "1.3.1" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "typing-extensions" + version = "4.10.0" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.8'" + + [[distribution]] + name = "exceptiongroup" + version = "1.2.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/8e/1c/beef724eaf5b01bb44b6338c8c3494eff7cab376fab4904cfbbc3585dc79/exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68", size = 26264 } + wheels = [{ url = "https://files.pythonhosted.org/packages/b8/9a/5028fd52db10e600f1c4674441b968cf2ea4959085bfb5b99fb1250e5f68/exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", size = 16210 }] + + [[distribution]] + name = "idna" + version = "3.6" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 } + wheels = [{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }] + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + + [[distribution.dependencies]] + name = "anyio" + version = "3.7.0" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "uv-public-pypackage" + version = "0.1.0" + source = "git+https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979" + + [[distribution]] + name = "sniffio" + version = "1.3.1" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } + wheels = [{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }] + + [[distribution]] + name = "typing-extensions" + version = "4.10.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [{ url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }] + + [[distribution]] + name = "uv-public-pypackage" + version = "0.1.0" + source = "git+https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979" + sdist = { url = "https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979" } + "### + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Audited 5 packages in [TIME] + "###); + + Ok(()) +} + +/// Update a PyPI requirement. +#[test] +fn update_registry() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio == 3.7.0 ; python_version >= '3.12'", + "anyio < 3.7.0 ; python_version < '3.12'", + ] + "#})?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning. + Resolved 10 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Downloaded 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + uv_snapshot!(context.filters(), context.add(&["anyio==4.3.0"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Downloaded 2 packages in [TIME] + Uninstalled 2 packages in [TIME] + Installed 2 packages in [TIME] + - anyio==3.7.0 + + anyio==4.3.0 + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio==4.3.0", + ] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "anyio" + version = "4.3.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 } + wheels = [{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 }] + + [[distribution.dependencies]] + name = "exceptiongroup" + version = "1.2.0" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.11'" + + [[distribution.dependencies]] + name = "idna" + version = "3.6" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "sniffio" + version = "1.3.1" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "typing-extensions" + version = "4.10.0" + source = "registry+https://pypi.org/simple" + marker = "python_version < '3.11'" + + [[distribution]] + name = "exceptiongroup" + version = "1.2.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/8e/1c/beef724eaf5b01bb44b6338c8c3494eff7cab376fab4904cfbbc3585dc79/exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68", size = 26264 } + wheels = [{ url = "https://files.pythonhosted.org/packages/b8/9a/5028fd52db10e600f1c4674441b968cf2ea4959085bfb5b99fb1250e5f68/exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", size = 16210 }] + + [[distribution]] + name = "idna" + version = "3.6" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 } + wheels = [{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }] + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + + [[distribution.dependencies]] + name = "anyio" + version = "4.3.0" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "sniffio" + version = "1.3.1" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } + wheels = [{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }] + + [[distribution]] + name = "typing-extensions" + version = "4.10.0" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [{ url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }] + "### + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Audited 4 packages in [TIME] + "###); + + Ok(()) +} + +/// Remove a PyPI requirement. +#[test] +fn remove_registry() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + "#})?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning. + Resolved 6 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Downloaded 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + uv_snapshot!(context.filters(), context.remove(&["anyio"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv remove` is experimental and may change without warning. + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + "### + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Audited 1 package in [TIME] + "###); + + Ok(()) +} + +/// Remove a PyPI requirement that occurs multiple times. +#[test] +fn remove_all_registry() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio == 3.7.0 ; python_version >= '3.12'", + "anyio < 3.7.0 ; python_version < '3.12'", + ] + "#})?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning. + Resolved 10 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Downloaded 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + uv_snapshot!(context.filters(), context.remove(&["anyio"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv remove` is experimental and may change without warning. + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + "### + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Audited 1 package in [TIME] + "###); + + Ok(()) +}