Skip to content

Commit

Permalink
Initial implementation of uv add and uv remove (#4193)
Browse files Browse the repository at this point in the history
## Summary

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

First step for #3959 and
#3960.
  • Loading branch information
ibraheemdev authored Jun 11, 2024
1 parent 60431ce commit eefa9e6
Show file tree
Hide file tree
Showing 16 changed files with 1,278 additions and 17 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/uv-distribution/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions crates/uv-distribution/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod index;
mod locks;
mod metadata;
pub mod pyproject;
pub mod pyproject_mut;
mod reporter;
mod source;
mod workspace;
2 changes: 1 addition & 1 deletion crates/uv-distribution/src/metadata/requires_dist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion crates/uv-distribution/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>.
Expand Down
161 changes: 161 additions & 0 deletions crates/uv-distribution/src/pyproject_mut.rs
Original file line number Diff line number Diff line change
@@ -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);
}
12 changes: 6 additions & 6 deletions crates/uv-distribution/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
50 changes: 44 additions & 6 deletions crates/uv/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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)]
Expand Down
2 changes: 2 additions & 0 deletions crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading

0 comments on commit eefa9e6

Please sign in to comment.