Initial implementation of uv add and uv remove (#4193)

## Summary

Basic implementation of `uv add` and `uv remove` that supports writing
PEP508 requirements to `project.dependencies`.

First step for https://github.com/astral-sh/uv/issues/3959 and
https://github.com/astral-sh/uv/issues/3960.
This commit is contained in:
Ibraheem Ahmed 2024-06-11 09:21:28 -04:00 committed by GitHub
parent 60431ce78c
commit eefa9e62fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1278 additions and 17 deletions

1
Cargo.lock generated
View file

@ -4681,6 +4681,7 @@ dependencies = [
"tokio",
"tokio-util",
"toml",
"toml_edit",
"tracing",
"url",
"uv-cache",

View file

@ -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 }

View file

@ -14,6 +14,7 @@ mod index;
mod locks;
mod metadata;
pub mod pyproject;
pub mod pyproject_mut;
mod reporter;
mod source;
mod workspace;

View file

@ -156,7 +156,7 @@ mod test {
use crate::{ProjectWorkspace, RequiresDist};
async fn requires_dist_from_pyproject_toml(contents: &str) -> anyhow::Result<RequiresDist> {
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,

View file

@ -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<Project>,
/// Tool-specific metadata.
pub tool: Option<Tool>,
/// 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<Self, toml::de::Error> {
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 <https://packaging.python.org/en/latest/specifications/pyproject-toml>.

View file

@ -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<TomlError>),
#[error("Dependencies in `pyproject.toml` are malformed")]
MalformedDependencies,
}
impl PyProjectTomlMut {
/// Initialize a `PyProjectTomlMut` from a `PyProjectToml`.
pub fn from_toml(pyproject: &PyProjectToml) -> Result<Self, Error> {
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<Vec<Requirement>, 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::<Vec<_>>();
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<VerbatimParsedUrl>> {
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<Item = &str> {
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);
}

View file

@ -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

View file

@ -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<String>,
/// 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<String>,
}
#[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<PackageName>,
/// 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<String>,
}
#[derive(Args)]

View file

@ -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")]

View file

@ -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<String>,
python: Option<String>,
preview: PreviewMode,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
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)
}

View file

@ -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;

View file

@ -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<PackageName>,
python: Option<String>,
preview: PreviewMode,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
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)
}

View file

@ -637,6 +637,38 @@ async fn run() -> Result<ExitStatus> {
)
.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,

View file

@ -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<String>,
pub(crate) python: Option<String>,
}
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<Workspace>) -> 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<PackageName>,
pub(crate) python: Option<String>,
}
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<Workspace>) -> 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)]

View file

@ -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)
}

716
crates/uv/tests/edit.rs Normal file
View file

@ -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(())
}