Skip to content

Commit eefa9e6

Browse files
authored
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 #3959 and #3960.
1 parent 60431ce commit eefa9e6

File tree

16 files changed

+1278
-17
lines changed

16 files changed

+1278
-17
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-distribution/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ thiserror = { workspace = true }
4848
tokio = { workspace = true }
4949
tokio-util = { workspace = true, features = ["compat"] }
5050
toml = { workspace = true }
51+
toml_edit = { workspace = true }
5152
tracing = { workspace = true }
5253
url = { workspace = true }
5354
zip = { workspace = true }

crates/uv-distribution/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod index;
1414
mod locks;
1515
mod metadata;
1616
pub mod pyproject;
17+
pub mod pyproject_mut;
1718
mod reporter;
1819
mod source;
1920
mod workspace;

crates/uv-distribution/src/metadata/requires_dist.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ mod test {
156156
use crate::{ProjectWorkspace, RequiresDist};
157157

158158
async fn requires_dist_from_pyproject_toml(contents: &str) -> anyhow::Result<RequiresDist> {
159-
let pyproject_toml: PyProjectToml = toml::from_str(contents)?;
159+
let pyproject_toml = PyProjectToml::from_string(contents.to_string())?;
160160
let path = Path::new("pyproject.toml");
161161
let project_workspace = ProjectWorkspace::from_project(
162162
path,

crates/uv-distribution/src/pyproject.rs

+21-1
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,35 @@ use pypi_types::VerbatimParsedUrl;
1818
use uv_normalize::{ExtraName, PackageName};
1919

2020
/// A `pyproject.toml` as specified in PEP 517.
21-
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
21+
#[derive(Serialize, Deserialize, Debug, Clone)]
2222
#[serde(rename_all = "kebab-case")]
2323
pub struct PyProjectToml {
2424
/// PEP 621-compliant project metadata.
2525
pub project: Option<Project>,
2626
/// Tool-specific metadata.
2727
pub tool: Option<Tool>,
28+
/// The raw unserialized document.
29+
#[serde(skip)]
30+
pub(crate) raw: String,
31+
}
32+
33+
impl PyProjectToml {
34+
/// Parse a `PyProjectToml` from a raw TOML string.
35+
pub fn from_string(raw: String) -> Result<Self, toml::de::Error> {
36+
let pyproject = toml::from_str(&raw)?;
37+
Ok(PyProjectToml { raw, ..pyproject })
38+
}
2839
}
2940

41+
// Ignore raw document in comparison.
42+
impl PartialEq for PyProjectToml {
43+
fn eq(&self, other: &Self) -> bool {
44+
self.project.eq(&other.project) && self.tool.eq(&other.tool)
45+
}
46+
}
47+
48+
impl Eq for PyProjectToml {}
49+
3050
/// PEP 621 project metadata (`project`).
3151
///
3252
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
use std::fmt;
2+
use std::str::FromStr;
3+
4+
use thiserror::Error;
5+
use toml_edit::{Array, DocumentMut, Item, RawString, TomlError, Value};
6+
7+
use pep508_rs::{PackageName, Requirement};
8+
use pypi_types::VerbatimParsedUrl;
9+
10+
use crate::pyproject::PyProjectToml;
11+
12+
/// Raw and mutable representation of a `pyproject.toml`.
13+
///
14+
/// This is useful for operations that require editing an existing `pyproject.toml` while
15+
/// preserving comments and other structure, such as `uv add` and `uv remove`.
16+
pub struct PyProjectTomlMut {
17+
doc: DocumentMut,
18+
}
19+
20+
#[derive(Error, Debug)]
21+
pub enum Error {
22+
#[error("Failed to parse `pyproject.toml`")]
23+
Parse(#[from] Box<TomlError>),
24+
#[error("Dependencies in `pyproject.toml` are malformed")]
25+
MalformedDependencies,
26+
}
27+
28+
impl PyProjectTomlMut {
29+
/// Initialize a `PyProjectTomlMut` from a `PyProjectToml`.
30+
pub fn from_toml(pyproject: &PyProjectToml) -> Result<Self, Error> {
31+
Ok(Self {
32+
doc: pyproject.raw.parse().map_err(Box::new)?,
33+
})
34+
}
35+
36+
/// Adds a dependency.
37+
pub fn add_dependency(&mut self, req: &Requirement) -> Result<(), Error> {
38+
let deps = &mut self.doc["project"]["dependencies"];
39+
if deps.is_none() {
40+
*deps = Item::Value(Value::Array(Array::new()));
41+
}
42+
let deps = deps.as_array_mut().ok_or(Error::MalformedDependencies)?;
43+
44+
// Try to find matching dependencies.
45+
let mut to_replace = Vec::new();
46+
for (i, dep) in deps.iter().enumerate() {
47+
if dep
48+
.as_str()
49+
.and_then(try_parse_requirement)
50+
.filter(|dep| dep.name == req.name)
51+
.is_some()
52+
{
53+
to_replace.push(i);
54+
}
55+
}
56+
57+
if to_replace.is_empty() {
58+
deps.push(req.to_string());
59+
} else {
60+
// Replace the first occurrence of the dependency and remove the rest.
61+
deps.replace(to_replace[0], req.to_string());
62+
for &i in to_replace[1..].iter().rev() {
63+
deps.remove(i);
64+
}
65+
}
66+
67+
reformat_array_multiline(deps);
68+
Ok(())
69+
}
70+
71+
/// Removes all occurrences of dependencies with the given name.
72+
pub fn remove_dependency(&mut self, req: &PackageName) -> Result<Vec<Requirement>, Error> {
73+
let deps = &mut self.doc["project"]["dependencies"];
74+
if deps.is_none() {
75+
return Ok(Vec::new());
76+
}
77+
78+
let deps = deps.as_array_mut().ok_or(Error::MalformedDependencies)?;
79+
80+
// Try to find matching dependencies.
81+
let mut to_remove = Vec::new();
82+
for (i, dep) in deps.iter().enumerate() {
83+
if dep
84+
.as_str()
85+
.and_then(try_parse_requirement)
86+
.filter(|dep| dep.name == *req)
87+
.is_some()
88+
{
89+
to_remove.push(i);
90+
}
91+
}
92+
93+
let removed = to_remove
94+
.into_iter()
95+
.rev() // Reverse to preserve indices as we remove them.
96+
.filter_map(|i| {
97+
deps.remove(i)
98+
.as_str()
99+
.and_then(|req| Requirement::from_str(req).ok())
100+
})
101+
.collect::<Vec<_>>();
102+
103+
if !removed.is_empty() {
104+
reformat_array_multiline(deps);
105+
}
106+
107+
Ok(removed)
108+
}
109+
}
110+
111+
impl fmt::Display for PyProjectTomlMut {
112+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113+
self.doc.fmt(f)
114+
}
115+
}
116+
117+
fn try_parse_requirement(req: &str) -> Option<Requirement<VerbatimParsedUrl>> {
118+
Requirement::from_str(req).ok()
119+
}
120+
121+
/// Reformats a TOML array to multi line while trying to preserve all comments
122+
/// and move them around. This also formats the array to have a trailing comma.
123+
fn reformat_array_multiline(deps: &mut Array) {
124+
fn find_comments(s: Option<&RawString>) -> impl Iterator<Item = &str> {
125+
s.and_then(|x| x.as_str())
126+
.unwrap_or("")
127+
.lines()
128+
.filter_map(|line| {
129+
let line = line.trim();
130+
line.starts_with('#').then_some(line)
131+
})
132+
}
133+
134+
for item in deps.iter_mut() {
135+
let decor = item.decor_mut();
136+
let mut prefix = String::new();
137+
for comment in find_comments(decor.prefix()).chain(find_comments(decor.suffix())) {
138+
prefix.push_str("\n ");
139+
prefix.push_str(comment);
140+
}
141+
prefix.push_str("\n ");
142+
decor.set_prefix(prefix);
143+
decor.set_suffix("");
144+
}
145+
146+
deps.set_trailing(&{
147+
let mut comments = find_comments(Some(deps.trailing())).peekable();
148+
let mut rv = String::new();
149+
if comments.peek().is_some() {
150+
for comment in comments {
151+
rv.push_str("\n ");
152+
rv.push_str(comment);
153+
}
154+
}
155+
if !rv.is_empty() || !deps.is_empty() {
156+
rv.push('\n');
157+
}
158+
rv
159+
});
160+
deps.set_trailing_comma(true);
161+
}

crates/uv-distribution/src/workspace.rs

+6-6
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ impl Workspace {
7575

7676
let pyproject_path = project_root.join("pyproject.toml");
7777
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
78-
let pyproject_toml: PyProjectToml = toml::from_str(&contents)
78+
let pyproject_toml = PyProjectToml::from_string(contents)
7979
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
8080

8181
let project_path = absolutize_path(project_root)
@@ -242,7 +242,7 @@ impl Workspace {
242242
if let Some(project) = &workspace_pyproject_toml.project {
243243
let pyproject_path = workspace_root.join("pyproject.toml");
244244
let contents = fs_err::read_to_string(&pyproject_path)?;
245-
let pyproject_toml = toml::from_str(&contents)
245+
let pyproject_toml = PyProjectToml::from_string(contents)
246246
.map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?;
247247

248248
debug!(
@@ -297,7 +297,7 @@ impl Workspace {
297297
// Read the member `pyproject.toml`.
298298
let pyproject_path = member_root.join("pyproject.toml");
299299
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
300-
let pyproject_toml: PyProjectToml = toml::from_str(&contents)
300+
let pyproject_toml = PyProjectToml::from_string(contents)
301301
.map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?;
302302

303303
// Extract the package name.
@@ -490,7 +490,7 @@ impl ProjectWorkspace {
490490
// Read the current `pyproject.toml`.
491491
let pyproject_path = project_root.join("pyproject.toml");
492492
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
493-
let pyproject_toml: PyProjectToml = toml::from_str(&contents)
493+
let pyproject_toml = PyProjectToml::from_string(contents)
494494
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
495495

496496
// It must have a `[project]` table.
@@ -514,7 +514,7 @@ impl ProjectWorkspace {
514514
// No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`.
515515
return Ok(None);
516516
};
517-
let pyproject_toml: PyProjectToml = toml::from_str(&contents)
517+
let pyproject_toml = PyProjectToml::from_string(contents)
518518
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
519519

520520
// Extract the `[project]` metadata.
@@ -656,7 +656,7 @@ async fn find_workspace(
656656

657657
// Read the `pyproject.toml`.
658658
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
659-
let pyproject_toml: PyProjectToml = toml::from_str(&contents)
659+
let pyproject_toml = PyProjectToml::from_string(contents)
660660
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
661661

662662
return if let Some(workspace) = pyproject_toml

crates/uv/src/cli.rs

+44-6
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ pub(crate) enum Commands {
153153
/// Resolve the project requirements into a lockfile.
154154
#[clap(hide = true)]
155155
Lock(LockArgs),
156+
/// Add one or more packages to the project requirements.
157+
#[clap(hide = true)]
158+
Add(AddArgs),
159+
/// Remove one or more packages from the project requirements.
160+
#[clap(hide = true)]
161+
Remove(RemoveArgs),
156162
/// Display uv's version
157163
Version {
158164
#[arg(long, value_enum, default_value = "text")]
@@ -1922,16 +1928,48 @@ pub(crate) struct LockArgs {
19221928

19231929
#[derive(Args)]
19241930
#[allow(clippy::struct_excessive_bools)]
1925-
struct AddArgs {
1926-
/// The name of the package to add (e.g., `Django==4.2.6`).
1927-
name: String,
1931+
pub(crate) struct AddArgs {
1932+
/// The packages to remove, as PEP 508 requirements (e.g., `flask==2.2.3`).
1933+
#[arg(required = true)]
1934+
pub(crate) requirements: Vec<String>,
1935+
1936+
/// The Python interpreter into which packages should be installed.
1937+
///
1938+
/// By default, `uv` installs into the virtual environment in the current working directory or
1939+
/// any parent directory. The `--python` option allows you to specify a different interpreter,
1940+
/// which is intended for use in continuous integration (CI) environments or other automated
1941+
/// workflows.
1942+
///
1943+
/// Supported formats:
1944+
/// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or
1945+
/// `python3.10` on Linux and macOS.
1946+
/// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`.
1947+
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
1948+
#[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)]
1949+
pub(crate) python: Option<String>,
19281950
}
19291951

19301952
#[derive(Args)]
19311953
#[allow(clippy::struct_excessive_bools)]
1932-
struct RemoveArgs {
1933-
/// The name of the package to remove (e.g., `Django`).
1934-
name: PackageName,
1954+
pub(crate) struct RemoveArgs {
1955+
/// The names of the packages to remove (e.g., `flask`).
1956+
#[arg(required = true)]
1957+
pub(crate) requirements: Vec<PackageName>,
1958+
1959+
/// The Python interpreter into which packages should be installed.
1960+
///
1961+
/// By default, `uv` installs into the virtual environment in the current working directory or
1962+
/// any parent directory. The `--python` option allows you to specify a different interpreter,
1963+
/// which is intended for use in continuous integration (CI) environments or other automated
1964+
/// workflows.
1965+
///
1966+
/// Supported formats:
1967+
/// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or
1968+
/// `python3.10` on Linux and macOS.
1969+
/// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`.
1970+
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
1971+
#[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)]
1972+
pub(crate) python: Option<String>,
19351973
}
19361974

19371975
#[derive(Args)]

crates/uv/src/commands/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ pub(crate) use pip::list::pip_list;
1616
pub(crate) use pip::show::pip_show;
1717
pub(crate) use pip::sync::pip_sync;
1818
pub(crate) use pip::uninstall::pip_uninstall;
19+
pub(crate) use project::add::add;
1920
pub(crate) use project::lock::lock;
21+
pub(crate) use project::remove::remove;
2022
pub(crate) use project::run::run;
2123
pub(crate) use project::sync::sync;
2224
#[cfg(feature = "self-update")]

0 commit comments

Comments
 (0)