Support PEP 723 scripts in uv add and uv remove (#5995)

## Summary

Resolves https://github.com/astral-sh/uv/issues/4667

## Test Plan

`cargo test`
This commit is contained in:
Ahmed Ilyas 2024-08-11 03:40:59 +02:00 committed by GitHub
parent 9b8c07bf18
commit 2d53e35e39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1215 additions and 289 deletions

View file

@ -2450,6 +2450,16 @@ pub struct AddArgs {
#[arg(long, conflicts_with = "isolated")] #[arg(long, conflicts_with = "isolated")]
pub package: Option<PackageName>, pub package: Option<PackageName>,
/// Add the dependency to the specified Python script, rather than to a project.
///
/// If provided, uv will add the dependency to the script's inline metadata
/// table, in adhere with PEP 723. If no such inline metadata table is present,
/// a new one will be created and added to the script. When executed via `uv run`,
/// uv will create a temporary environment for the script with all inline
/// dependencies installed.
#[arg(long, conflicts_with = "dev", conflicts_with = "optional")]
pub script: Option<PathBuf>,
/// The Python interpreter to use for resolving and syncing. /// The Python interpreter to use for resolving and syncing.
/// ///
/// See `uv help python` for details on Python discovery and supported /// See `uv help python` for details on Python discovery and supported
@ -2509,6 +2519,13 @@ pub struct RemoveArgs {
#[arg(long, conflicts_with = "isolated")] #[arg(long, conflicts_with = "isolated")]
pub package: Option<PackageName>, pub package: Option<PackageName>,
/// Remove the dependency from the specified Python script, rather than from a project.
///
/// If provided, uv will remove the dependency from the script's inline metadata
/// table, in adhere with PEP 723.
#[arg(long)]
pub script: Option<PathBuf>,
/// The Python interpreter to use for resolving and syncing. /// The Python interpreter to use for resolving and syncing.
/// ///
/// See `uv help python` for details on Python discovery and supported /// See `uv help python` for details on Python discovery and supported

View file

@ -15,10 +15,8 @@ uv-settings = { workspace = true }
uv-workspace = { workspace = true } uv-workspace = { workspace = true }
fs-err = { workspace = true, features = ["tokio"] } fs-err = { workspace = true, features = ["tokio"] }
indoc = { workspace = true }
memchr = { workspace = true } memchr = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true } thiserror = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
[dev-dependencies]
indoc = { workspace = true }

View file

@ -1,9 +1,11 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::LazyLock; use std::sync::LazyLock;
use memchr::memmem::Finder; use memchr::memmem::Finder;
use pep440_rs::VersionSpecifiers;
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
@ -17,8 +19,14 @@ static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"))
/// A PEP 723 script, including its [`Pep723Metadata`]. /// A PEP 723 script, including its [`Pep723Metadata`].
#[derive(Debug)] #[derive(Debug)]
pub struct Pep723Script { pub struct Pep723Script {
/// The path to the Python script.
pub path: PathBuf, pub path: PathBuf,
/// The parsed [`Pep723Metadata`] table from the script.
pub metadata: Pep723Metadata, pub metadata: Pep723Metadata,
/// The content of the script after the metadata table.
pub raw: String,
/// The content of the script before the metadata table.
pub prelude: String,
} }
impl Pep723Script { impl Pep723Script {
@ -26,12 +34,76 @@ impl Pep723Script {
/// ///
/// See: <https://peps.python.org/pep-0723/> /// See: <https://peps.python.org/pep-0723/>
pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> { pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
let metadata = Pep723Metadata::read(&file).await?; let contents = match fs_err::tokio::read(&file).await {
Ok(metadata.map(|metadata| Self { Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
// Extract the `script` tag.
let Some(script_tag) = ScriptTag::parse(&contents)? else {
return Ok(None);
};
// Parse the metadata.
let metadata = Pep723Metadata::from_str(&script_tag.metadata)?;
Ok(Some(Self {
path: file.as_ref().to_path_buf(), path: file.as_ref().to_path_buf(),
metadata, metadata,
raw: script_tag.script,
prelude: script_tag.prelude,
})) }))
} }
/// Reads a Python script and generates a default PEP 723 metadata table.
///
/// See: <https://peps.python.org/pep-0723/>
pub async fn create(
file: impl AsRef<Path>,
requires_python: &VersionSpecifiers,
) -> Result<Self, Pep723Error> {
let contents = match fs_err::tokio::read(&file).await {
Ok(contents) => contents,
Err(err) => return Err(err.into()),
};
// Extract the `script` tag.
let default_metadata = indoc::formatdoc! {r#"
requires-python = "{requires_python}"
dependencies = []
"#,
requires_python = requires_python,
};
let (prelude, raw) = extract_shebang(&contents)?;
// Parse the metadata.
let metadata = Pep723Metadata::from_str(&default_metadata)?;
Ok(Self {
path: file.as_ref().to_path_buf(),
prelude: prelude.unwrap_or_default(),
metadata,
raw,
})
}
/// Replace the existing metadata in the file with new metadata and write the updated content.
pub async fn write(&self, metadata: &str) -> Result<(), Pep723Error> {
let content = format!(
"{}{}{}",
if self.prelude.is_empty() {
String::new()
} else {
format!("{}\n", self.prelude)
},
serialize_metadata(metadata),
self.raw
);
Ok(fs_err::tokio::write(&self.path, content).await?)
}
} }
/// PEP 723 metadata as parsed from a `script` comment block. /// PEP 723 metadata as parsed from a `script` comment block.
@ -41,30 +113,23 @@ impl Pep723Script {
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct Pep723Metadata { pub struct Pep723Metadata {
pub dependencies: Option<Vec<pep508_rs::Requirement<VerbatimParsedUrl>>>, pub dependencies: Option<Vec<pep508_rs::Requirement<VerbatimParsedUrl>>>,
pub requires_python: Option<pep440_rs::VersionSpecifiers>, pub requires_python: Option<VersionSpecifiers>,
pub tool: Option<Tool>, pub tool: Option<Tool>,
/// The raw unserialized document.
#[serde(skip)]
pub raw: String,
} }
impl Pep723Metadata { impl FromStr for Pep723Metadata {
/// Read the PEP 723 `script` metadata from a Python file, if it exists. type Err = Pep723Error;
///
/// See: <https://peps.python.org/pep-0723/>
pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
let contents = match fs_err::tokio::read(file).await {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
// Extract the `script` tag. /// Parse `Pep723Metadata` from a raw TOML string.
let Some(contents) = extract_script_tag(&contents)? else { fn from_str(raw: &str) -> Result<Self, Self::Err> {
return Ok(None); let metadata = toml::from_str(raw)?;
}; Ok(Pep723Metadata {
raw: raw.to_string(),
// Parse the metadata. ..metadata
let metadata = toml::from_str(&contents)?; })
Ok(Some(metadata))
} }
} }
@ -94,120 +159,193 @@ pub enum Pep723Error {
Toml(#[from] toml::de::Error), Toml(#[from] toml::de::Error),
} }
/// Read the PEP 723 `script` metadata from a Python file, if it exists. #[derive(Debug, Clone, Eq, PartialEq)]
/// struct ScriptTag {
/// See: <https://peps.python.org/pep-0723/> /// The content of the script before the metadata block.
pub async fn read_pep723_metadata( prelude: String,
file: impl AsRef<Path>, /// The metadata block.
) -> Result<Option<Pep723Metadata>, Pep723Error> { metadata: String,
let contents = match fs_err::tokio::read(file).await { /// The content of the script after the metadata block.
Ok(contents) => contents, script: String,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
// Extract the `script` tag.
let Some(contents) = extract_script_tag(&contents)? else {
return Ok(None);
};
// Parse the metadata.
let metadata = toml::from_str(&contents)?;
Ok(Some(metadata))
} }
/// Given the contents of a Python file, extract the `script` metadata block, with leading comment impl ScriptTag {
/// hashes removed. /// Given the contents of a Python file, extract the `script` metadata block with leading
/// /// comment hashes removed, any preceding shebang or content (prelude), and the remaining Python
/// See: <https://peps.python.org/pep-0723/> /// script.
fn extract_script_tag(contents: &[u8]) -> Result<Option<String>, Pep723Error> { ///
// Identify the opening pragma. /// Given the following input string representing the contents of a Python script:
let Some(index) = FINDER.find(contents) else { ///
return Ok(None); /// ```python
}; /// #!/usr/bin/env python3
/// # /// script
/// # requires-python = '>=3.11'
/// # dependencies = [
/// # 'requests<3',
/// # 'rich',
/// # ]
/// # ///
///
/// import requests
///
/// print("Hello, World!")
/// ```
///
/// This function would return:
///
/// - Preamble: `#!/usr/bin/env python3\n`
/// - Metadata: `requires-python = '>=3.11'\ndependencies = [\n 'requests<3',\n 'rich',\n]`
/// - Script: `import requests\n\nprint("Hello, World!")\n`
///
/// See: <https://peps.python.org/pep-0723/>
fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
// Identify the opening pragma.
let Some(index) = FINDER.find(contents) else {
return Ok(None);
};
// The opening pragma must be the first line, or immediately preceded by a newline. // The opening pragma must be the first line, or immediately preceded by a newline.
if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) { if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) {
return Ok(None); return Ok(None);
}
// Extract the preceding content.
let prelude = std::str::from_utf8(&contents[..index])?;
// Decode as UTF-8.
let contents = &contents[index..];
let contents = std::str::from_utf8(contents)?;
let mut lines = contents.lines();
// Ensure that the first line is exactly `# /// script`.
if !lines.next().is_some_and(|line| line == "# /// script") {
return Ok(None);
}
// > Every line between these two lines (# /// TYPE and # ///) MUST be a comment starting
// > with #. If there are characters after the # then the first character MUST be a space. The
// > embedded content is formed by taking away the first two characters of each line if the
// > second character is a space, otherwise just the first character (which means the line
// > consists of only a single #).
let mut toml = vec![];
// Extract the content that follows the metadata block.
let mut python_script = vec![];
while let Some(line) = lines.next() {
// Remove the leading `#`.
let Some(line) = line.strip_prefix('#') else {
python_script.push(line);
python_script.extend(lines);
break;
};
// If the line is empty, continue.
if line.is_empty() {
toml.push("");
continue;
}
// Otherwise, the line _must_ start with ` `.
let Some(line) = line.strip_prefix(' ') else {
python_script.push(line);
python_script.extend(lines);
break;
};
toml.push(line);
}
// Find the closing `# ///`. The precedence is such that we need to identify the _last_ such
// line.
//
// For example, given:
// ```python
// # /// script
// #
// # ///
// #
// # ///
// ```
//
// The latter `///` is the closing pragma
let Some(index) = toml.iter().rev().position(|line| *line == "///") else {
return Ok(None);
};
let index = toml.len() - index;
// Discard any lines after the closing `# ///`.
//
// For example, given:
// ```python
// # /// script
// #
// # ///
// #
// #
// ```
//
// We need to discard the last two lines.
toml.truncate(index - 1);
// Join the lines into a single string.
let prelude = prelude.to_string();
let metadata = toml.join("\n") + "\n";
let script = python_script.join("\n") + "\n";
Ok(Some(Self {
prelude,
metadata,
script,
}))
} }
}
// Decode as UTF-8. /// Extracts the shebang line from the given file contents and returns it along with the remaining
let contents = &contents[index..]; /// content.
fn extract_shebang(contents: &[u8]) -> Result<(Option<String>, String), Pep723Error> {
let contents = std::str::from_utf8(contents)?; let contents = std::str::from_utf8(contents)?;
let mut lines = contents.lines(); let mut lines = contents.lines();
// Ensure that the first line is exactly `# /// script`. // Check the first line for a shebang
if !lines.next().is_some_and(|line| line == "# /// script") { if let Some(first_line) = lines.next() {
return Ok(None); if first_line.starts_with("#!") {
} let shebang = first_line.to_string();
let remaining_content: String = lines.collect::<Vec<&str>>().join("\n");
// > Every line between these two lines (# /// TYPE and # ///) MUST be a comment starting return Ok((Some(shebang), remaining_content));
// > with #. If there are characters after the # then the first character MUST be a space. The
// > embedded content is formed by taking away the first two characters of each line if the
// > second character is a space, otherwise just the first character (which means the line
// > consists of only a single #).
let mut toml = vec![];
for line in lines {
// Remove the leading `#`.
let Some(line) = line.strip_prefix('#') else {
break;
};
// If the line is empty, continue.
if line.is_empty() {
toml.push("");
continue;
} }
// Otherwise, the line _must_ start with ` `.
let Some(line) = line.strip_prefix(' ') else {
break;
};
toml.push(line);
} }
// Find the closing `# ///`. The precedence is such that we need to identify the _last_ such Ok((None, contents.to_string()))
// line. }
//
// For example, given:
// ```python
// # /// script
// #
// # ///
// #
// # ///
// ```
//
// The latter `///` is the closing pragma
let Some(index) = toml.iter().rev().position(|line| *line == "///") else {
return Ok(None);
};
let index = toml.len() - index;
// Discard any lines after the closing `# ///`. /// Formats the provided metadata by prefixing each line with `#` and wrapping it with script markers.
// fn serialize_metadata(metadata: &str) -> String {
// For example, given: let mut output = String::with_capacity(metadata.len() + 2);
// ```python
// # /// script
// #
// # ///
// #
// #
// ```
//
// We need to discard the last two lines.
toml.truncate(index - 1);
// Join the lines into a single string. output.push_str("# /// script\n");
let toml = toml.join("\n") + "\n";
Ok(Some(toml)) for line in metadata.lines() {
if line.is_empty() {
output.push('\n');
} else {
output.push_str("# ");
output.push_str(line);
output.push('\n');
}
}
output.push_str("# ///\n");
output
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{serialize_metadata, ScriptTag};
#[test] #[test]
fn missing_space() { fn missing_space() {
let contents = indoc::indoc! {r" let contents = indoc::indoc! {r"
@ -216,10 +354,7 @@ mod tests {
# /// # ///
"}; "};
assert_eq!( assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None);
super::extract_script_tag(contents.as_bytes()).unwrap(),
None
);
} }
#[test] #[test]
@ -233,10 +368,7 @@ mod tests {
# ] # ]
"}; "};
assert_eq!( assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None);
super::extract_script_tag(contents.as_bytes()).unwrap(),
None
);
} }
#[test] #[test]
@ -253,10 +385,7 @@ mod tests {
# #
"}; "};
assert_eq!( assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None);
super::extract_script_tag(contents.as_bytes()).unwrap(),
None
);
} }
#[test] #[test]
@ -269,9 +398,15 @@ mod tests {
# 'rich', # 'rich',
# ] # ]
# /// # ///
import requests
from rich.pretty import pprint
resp = requests.get('https://peps.python.org/api/peps.json')
data = resp.json()
"}; "};
let expected = indoc::indoc! {r" let expected_metadata = indoc::indoc! {r"
requires-python = '>=3.11' requires-python = '>=3.11'
dependencies = [ dependencies = [
'requests<3', 'requests<3',
@ -279,13 +414,64 @@ mod tests {
] ]
"}; "};
let actual = super::extract_script_tag(contents.as_bytes()) let expected_data = indoc::indoc! {r"
.unwrap()
.unwrap();
assert_eq!(actual, expected); import requests
from rich.pretty import pprint
resp = requests.get('https://peps.python.org/api/peps.json')
data = resp.json()
"};
let actual = ScriptTag::parse(contents.as_bytes()).unwrap().unwrap();
assert_eq!(actual.prelude, String::new());
assert_eq!(actual.metadata, expected_metadata);
assert_eq!(actual.script, expected_data);
} }
#[test]
fn simple_with_shebang() {
let contents = indoc::indoc! {r"
#!/usr/bin/env python3
# /// script
# requires-python = '>=3.11'
# dependencies = [
# 'requests<3',
# 'rich',
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get('https://peps.python.org/api/peps.json')
data = resp.json()
"};
let expected_metadata = indoc::indoc! {r"
requires-python = '>=3.11'
dependencies = [
'requests<3',
'rich',
]
"};
let expected_data = indoc::indoc! {r"
import requests
from rich.pretty import pprint
resp = requests.get('https://peps.python.org/api/peps.json')
data = resp.json()
"};
let actual = ScriptTag::parse(contents.as_bytes()).unwrap().unwrap();
assert_eq!(actual.prelude, "#!/usr/bin/env python3\n".to_string());
assert_eq!(actual.metadata, expected_metadata);
assert_eq!(actual.script, expected_data);
}
#[test] #[test]
fn embedded_comment() { fn embedded_comment() {
let contents = indoc::indoc! {r" let contents = indoc::indoc! {r"
@ -310,9 +496,10 @@ mod tests {
''' '''
"}; "};
let actual = super::extract_script_tag(contents.as_bytes()) let actual = ScriptTag::parse(contents.as_bytes())
.unwrap() .unwrap()
.unwrap(); .unwrap()
.metadata;
assert_eq!(actual, expected); assert_eq!(actual, expected);
} }
@ -339,10 +526,44 @@ mod tests {
] ]
"}; "};
let actual = super::extract_script_tag(contents.as_bytes()) let actual = ScriptTag::parse(contents.as_bytes())
.unwrap() .unwrap()
.unwrap(); .unwrap()
.metadata;
assert_eq!(actual, expected); assert_eq!(actual, expected);
} }
#[test]
fn test_serialize_metadata_formatting() {
let metadata = indoc::indoc! {r"
requires-python = '>=3.11'
dependencies = [
'requests<3',
'rich',
]
"};
let expected_output = indoc::indoc! {r"
# /// script
# requires-python = '>=3.11'
# dependencies = [
# 'requests<3',
# 'rich',
# ]
# ///
"};
let result = serialize_metadata(metadata);
assert_eq!(result, expected_output);
}
#[test]
fn test_serialize_metadata_empty() {
let metadata = "";
let expected_output = "# /// script\n# ///\n";
let result = serialize_metadata(metadata);
assert_eq!(result, expected_output);
}
} }

View file

@ -8,7 +8,7 @@ use thiserror::Error;
use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value};
use uv_fs::PortablePath; use uv_fs::PortablePath;
use crate::pyproject::{DependencyType, PyProjectToml, Source}; use crate::pyproject::{DependencyType, Source};
/// Raw and mutable representation of a `pyproject.toml`. /// Raw and mutable representation of a `pyproject.toml`.
/// ///
@ -16,6 +16,7 @@ use crate::pyproject::{DependencyType, PyProjectToml, Source};
/// preserving comments and other structure, such as `uv add` and `uv remove`. /// preserving comments and other structure, such as `uv add` and `uv remove`.
pub struct PyProjectTomlMut { pub struct PyProjectTomlMut {
doc: DocumentMut, doc: DocumentMut,
target: DependencyTarget,
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -47,11 +48,21 @@ pub enum ArrayEdit {
Add(usize), Add(usize),
} }
/// Specifies whether dependencies are added to a script file or a `pyproject.toml` file.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum DependencyTarget {
/// A PEP 723 script, with inline metadata.
Script,
/// A project with a `pyproject.toml`.
PyProjectToml,
}
impl PyProjectTomlMut { impl PyProjectTomlMut {
/// Initialize a [`PyProjectTomlMut`] from a [`PyProjectToml`]. /// Initialize a [`PyProjectTomlMut`] from a [`str`].
pub fn from_toml(pyproject: &PyProjectToml) -> Result<Self, Error> { pub fn from_toml(raw: &str, target: DependencyTarget) -> Result<Self, Error> {
Ok(Self { Ok(Self {
doc: pyproject.raw.parse().map_err(Box::new)?, doc: raw.parse().map_err(Box::new)?,
target,
}) })
} }
@ -83,6 +94,34 @@ impl PyProjectTomlMut {
Ok(()) Ok(())
} }
/// Retrieves a mutable reference to the root [`Table`] of the TOML document, creating the
/// `project` table if necessary.
fn doc(&mut self) -> Result<&mut Table, Error> {
let doc = match self.target {
DependencyTarget::Script => self.doc.as_table_mut(),
DependencyTarget::PyProjectToml => self
.doc
.entry("project")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or(Error::MalformedDependencies)?,
};
Ok(doc)
}
/// Retrieves an optional mutable reference to the `project` [`Table`], returning `None` if it
/// doesn't exist.
fn doc_mut(&mut self) -> Result<Option<&mut Table>, Error> {
let doc = match self.target {
DependencyTarget::Script => Some(self.doc.as_table_mut()),
DependencyTarget::PyProjectToml => self
.doc
.get_mut("project")
.map(|project| project.as_table_mut().ok_or(Error::MalformedSources))
.transpose()?,
};
Ok(doc)
}
/// Adds a dependency to `project.dependencies`. /// Adds a dependency to `project.dependencies`.
/// ///
/// Returns `true` if the dependency was added, `false` if it was updated. /// Returns `true` if the dependency was added, `false` if it was updated.
@ -93,11 +132,7 @@ impl PyProjectTomlMut {
) -> Result<ArrayEdit, Error> { ) -> Result<ArrayEdit, Error> {
// Get or create `project.dependencies`. // Get or create `project.dependencies`.
let dependencies = self let dependencies = self
.doc .doc()?
.entry("project")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or(Error::MalformedDependencies)?
.entry("dependencies") .entry("dependencies")
.or_insert(Item::Value(Value::Array(Array::new()))) .or_insert(Item::Value(Value::Array(Array::new())))
.as_array_mut() .as_array_mut()
@ -158,11 +193,7 @@ impl PyProjectTomlMut {
) -> Result<ArrayEdit, Error> { ) -> Result<ArrayEdit, Error> {
// Get or create `project.optional-dependencies`. // Get or create `project.optional-dependencies`.
let optional_dependencies = self let optional_dependencies = self
.doc .doc()?
.entry("project")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or(Error::MalformedDependencies)?
.entry("optional-dependencies") .entry("optional-dependencies")
.or_insert(Item::Table(Table::new())) .or_insert(Item::Table(Table::new()))
.as_table_mut() .as_table_mut()
@ -192,11 +223,7 @@ impl PyProjectTomlMut {
) -> Result<(), Error> { ) -> Result<(), Error> {
// Get or create `project.dependencies`. // Get or create `project.dependencies`.
let dependencies = self let dependencies = self
.doc .doc()?
.entry("project")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or(Error::MalformedDependencies)?
.entry("dependencies") .entry("dependencies")
.or_insert(Item::Value(Value::Array(Array::new()))) .or_insert(Item::Value(Value::Array(Array::new())))
.as_array_mut() .as_array_mut()
@ -265,11 +292,7 @@ impl PyProjectTomlMut {
) -> Result<(), Error> { ) -> Result<(), Error> {
// Get or create `project.optional-dependencies`. // Get or create `project.optional-dependencies`.
let optional_dependencies = self let optional_dependencies = self
.doc .doc()?
.entry("project")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or(Error::MalformedDependencies)?
.entry("optional-dependencies") .entry("optional-dependencies")
.or_insert(Item::Table(Table::new())) .or_insert(Item::Table(Table::new()))
.as_table_mut() .as_table_mut()
@ -323,10 +346,7 @@ impl PyProjectTomlMut {
pub fn remove_dependency(&mut self, req: &PackageName) -> Result<Vec<Requirement>, Error> { pub fn remove_dependency(&mut self, req: &PackageName) -> Result<Vec<Requirement>, Error> {
// Try to get `project.dependencies`. // Try to get `project.dependencies`.
let Some(dependencies) = self let Some(dependencies) = self
.doc .doc_mut()?
.get_mut("project")
.map(|project| project.as_table_mut().ok_or(Error::MalformedSources))
.transpose()?
.and_then(|project| project.get_mut("dependencies")) .and_then(|project| project.get_mut("dependencies"))
.map(|dependencies| dependencies.as_array_mut().ok_or(Error::MalformedSources)) .map(|dependencies| dependencies.as_array_mut().ok_or(Error::MalformedSources))
.transpose()? .transpose()?
@ -372,10 +392,7 @@ impl PyProjectTomlMut {
) -> Result<Vec<Requirement>, Error> { ) -> Result<Vec<Requirement>, Error> {
// Try to get `project.optional-dependencies.<group>`. // Try to get `project.optional-dependencies.<group>`.
let Some(optional_dependencies) = self let Some(optional_dependencies) = self
.doc .doc_mut()?
.get_mut("project")
.map(|project| project.as_table_mut().ok_or(Error::MalformedSources))
.transpose()?
.and_then(|project| project.get_mut("optional-dependencies")) .and_then(|project| project.get_mut("optional-dependencies"))
.map(|extras| extras.as_table_mut().ok_or(Error::MalformedSources)) .map(|extras| extras.as_table_mut().ok_or(Error::MalformedSources))
.transpose()? .transpose()?

View file

@ -104,7 +104,7 @@ impl Workspace {
let pyproject_path = project_path.join("pyproject.toml"); let pyproject_path = project_path.join("pyproject.toml");
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
let pyproject_toml = PyProjectToml::from_string(contents) let pyproject_toml = PyProjectToml::from_string(contents.clone())
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
// Check if the project is explicitly marked as unmanaged. // Check if the project is explicitly marked as unmanaged.

View file

@ -1,10 +1,12 @@
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use pep508_rs::{ExtraName, Requirement, VersionOrUrl};
use rustc_hash::{FxBuildHasher, FxHashMap}; use rustc_hash::{FxBuildHasher, FxHashMap};
use tracing::debug; use tracing::debug;
use pep508_rs::{ExtraName, Requirement, VersionOrUrl};
use uv_auth::store_credentials_from_url; use uv_auth::store_credentials_from_url;
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder};
@ -15,20 +17,24 @@ use uv_dispatch::BuildDispatch;
use uv_distribution::DistributionDatabase; use uv_distribution::DistributionDatabase;
use uv_fs::CWD; use uv_fs::CWD;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_python::{
request_from_version_file, EnvironmentPreference, Interpreter, PythonDownloads,
PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest,
};
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
use uv_resolver::FlatIndex; use uv_resolver::{FlatIndex, RequiresPython};
use uv_scripts::Pep723Script;
use uv_types::{BuildIsolation, HashStrategy}; use uv_types::{BuildIsolation, HashStrategy};
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
use uv_workspace::pyproject::{DependencyType, Source, SourceError}; use uv_workspace::pyproject::{DependencyType, Source, SourceError};
use uv_workspace::pyproject_mut::{ArrayEdit, PyProjectTomlMut}; use uv_workspace::pyproject_mut::{ArrayEdit, DependencyTarget, PyProjectTomlMut};
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace}; use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace};
use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger};
use crate::commands::pip::operations::Modifications; use crate::commands::pip::operations::Modifications;
use crate::commands::pip::resolution_environment; use crate::commands::pip::resolution_environment;
use crate::commands::project::ProjectError; use crate::commands::project::ProjectError;
use crate::commands::reporters::ResolverReporter; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
use crate::commands::{pip, project, ExitStatus, SharedState}; use crate::commands::{pip, project, ExitStatus, SharedState};
use crate::printer::Printer; use crate::printer::Printer;
use crate::settings::ResolverInstallerSettings; use crate::settings::ResolverInstallerSettings;
@ -50,6 +56,7 @@ pub(crate) async fn add(
package: Option<PackageName>, package: Option<PackageName>,
python: Option<String>, python: Option<String>,
settings: ResolverInstallerSettings, settings: ResolverInstallerSettings,
script: Option<PathBuf>,
python_preference: PythonPreference, python_preference: PythonPreference,
python_downloads: PythonDownloads, python_downloads: PythonDownloads,
preview: PreviewMode, preview: PreviewMode,
@ -63,43 +70,139 @@ pub(crate) async fn add(
warn_user_once!("`uv add` is experimental and may change without warning"); warn_user_once!("`uv add` is experimental and may change without warning");
} }
// Find the project in the workspace. let reporter = PythonDownloadReporter::single(printer);
let project = if let Some(package) = package {
VirtualProject::Project(
Workspace::discover(&CWD, &DiscoveryOptions::default())
.await?
.with_current_project(package.clone())
.with_context(|| format!("Package `{package}` not found in workspace"))?,
)
} else {
VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await?
};
// For virtual projects, allow dev dependencies, but nothing else. let target = if let Some(script) = script {
if project.is_virtual() { // If we found a PEP 723 script and the user provided a project-only setting, warn.
match dependency_type { if package.is_some() {
DependencyType::Production => { warn_user_once!(
anyhow::bail!("Found a virtual workspace root, but virtual projects do not support production dependencies (instead, use: `{}`)", "uv add --dev".green()) "`--package` is a no-op for Python scripts with inline metadata, which always run in isolation"
} );
DependencyType::Optional(_) => { }
anyhow::bail!("Found a virtual workspace root, but virtual projects do not support optional dependencies (instead, use: `{}`)", "uv add --dev".green()) if locked {
} warn_user_once!(
DependencyType::Dev => (), "`--locked` is a no-op for Python scripts with inline metadata, which always run in isolation"
);
}
if frozen {
warn_user_once!(
"`--frozen` is a no-op for Python scripts with inline metadata, which always run in isolation"
);
}
if no_sync {
warn_user_once!(
"`--no_sync` is a no-op for Python scripts with inline metadata, which always run in isolation"
);
} }
}
// Discover or create the virtual environment. let client_builder = BaseClientBuilder::new()
let venv = project::get_or_init_environment( .connectivity(connectivity)
project.workspace(), .native_tls(native_tls);
python.as_deref().map(PythonRequest::parse),
python_preference, // If we found a script, add to the existing metadata. Otherwise, create a new inline
python_downloads, // metadata tag.
connectivity, let script = if let Some(script) = Pep723Script::read(&script).await? {
native_tls, script
cache, } else {
printer, let python_request = if let Some(request) = python.as_deref() {
) // (1) Explicit request from user
.await?; PythonRequest::parse(request)
} else if let Some(request) = request_from_version_file(&CWD).await? {
// (2) Request from `.python-version`
request
} else {
// (3) Assume any Python version
PythonRequest::Any
};
let interpreter = PythonInstallation::find_or_download(
Some(python_request),
EnvironmentPreference::Any,
python_preference,
python_downloads,
&client_builder,
cache,
Some(&reporter),
)
.await?
.into_interpreter();
let requires_python =
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version());
Pep723Script::create(&script, requires_python.specifiers()).await?
};
let python_request = if let Some(request) = python.as_deref() {
// (1) Explicit request from user
Some(PythonRequest::parse(request))
} else if let Some(request) = request_from_version_file(&CWD).await? {
// (2) Request from `.python-version`
Some(request)
} else {
// (3) `Requires-Python` in `pyproject.toml`
script
.metadata
.requires_python
.clone()
.map(|requires_python| {
PythonRequest::Version(VersionRequest::Range(requires_python))
})
};
let interpreter = PythonInstallation::find_or_download(
python_request,
EnvironmentPreference::Any,
python_preference,
python_downloads,
&client_builder,
cache,
Some(&reporter),
)
.await?
.into_interpreter();
Target::Script(script, Box::new(interpreter))
} else {
// Find the project in the workspace.
let project = if let Some(package) = package {
VirtualProject::Project(
Workspace::discover(&CWD, &DiscoveryOptions::default())
.await?
.with_current_project(package.clone())
.with_context(|| format!("Package `{package}` not found in workspace"))?,
)
} else {
VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await?
};
// For virtual projects, allow dev dependencies, but nothing else.
if project.is_virtual() {
match dependency_type {
DependencyType::Production => {
anyhow::bail!("Found a virtual workspace root, but virtual projects do not support production dependencies (instead, use: `{}`)", "uv add --dev".green())
}
DependencyType::Optional(_) => {
anyhow::bail!("Found a virtual workspace root, but virtual projects do not support optional dependencies (instead, use: `{}`)", "uv add --dev".green())
}
DependencyType::Dev => (),
}
}
// Discover or create the virtual environment.
let venv = project::get_or_init_environment(
project.workspace(),
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
cache,
printer,
)
.await?;
Target::Project(project, venv)
};
let client_builder = BaseClientBuilder::new() let client_builder = BaseClientBuilder::new()
.connectivity(connectivity) .connectivity(connectivity)
@ -120,7 +223,7 @@ pub(crate) async fn add(
// Determine the environment for the resolution. // Determine the environment for the resolution.
let (tags, markers) = let (tags, markers) =
resolution_environment(python_version, python_platform, venv.interpreter())?; resolution_environment(python_version, python_platform, target.interpreter())?;
// Add all authenticated sources to the cache. // Add all authenticated sources to the cache.
for url in settings.index_locations.urls() { for url in settings.index_locations.urls() {
@ -132,7 +235,7 @@ pub(crate) async fn add(
.index_urls(settings.index_locations.index_urls()) .index_urls(settings.index_locations.index_urls())
.index_strategy(settings.index_strategy) .index_strategy(settings.index_strategy)
.markers(&markers) .markers(&markers)
.platform(venv.interpreter().platform()) .platform(target.interpreter().platform())
.build(); .build();
// Initialize any shared state. // Initialize any shared state.
@ -153,7 +256,7 @@ pub(crate) async fn add(
&client, &client,
cache, cache,
&build_constraints, &build_constraints,
venv.interpreter(), target.interpreter(),
&settings.index_locations, &settings.index_locations,
&flat_index, &flat_index,
&state.index, &state.index,
@ -182,9 +285,16 @@ pub(crate) async fn add(
.resolve() .resolve()
.await?; .await?;
// Add the requirements to the `pyproject.toml`. // Add the requirements to the `pyproject.toml` or script.
let existing = project.pyproject_toml(); let mut toml = match &target {
let mut pyproject = PyProjectTomlMut::from_toml(existing)?; Target::Script(script, _) => {
PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script)
}
Target::Project(project, _) => PyProjectTomlMut::from_toml(
&project.pyproject_toml().raw,
DependencyTarget::PyProjectToml,
),
}?;
let mut edits = Vec::with_capacity(requirements.len()); let mut edits = Vec::with_capacity(requirements.len());
for mut requirement in requirements { for mut requirement in requirements {
// Add the specified extras. // Add the specified extras.
@ -192,48 +302,40 @@ pub(crate) async fn add(
requirement.extras.sort_unstable(); requirement.extras.sort_unstable();
requirement.extras.dedup(); requirement.extras.dedup();
let (requirement, source) = if raw_sources { let (requirement, source) = match target {
// Use the PEP 508 requirement directly. Target::Script(_, _) | Target::Project(_, _) if raw_sources => {
(pep508_rs::Requirement::from(requirement), None) (pep508_rs::Requirement::from(requirement), None)
} else { }
// Otherwise, try to construct the source. Target::Script(_, _) => resolve_requirement(
let workspace = project requirement,
.workspace() false,
.packages()
.contains_key(&requirement.name);
let result = Source::from_requirement(
&requirement.name,
requirement.source.clone(),
workspace,
editable, editable,
rev.clone(), rev.clone(),
tag.clone(), tag.clone(),
branch.clone(), branch.clone(),
); )?,
Target::Project(ref project, _) => {
let source = match result { let workspace = project
Ok(source) => source, .workspace()
Err(SourceError::UnresolvedReference(rev)) => { .packages()
anyhow::bail!("Cannot resolve Git reference `{rev}` for requirement `{name}`. Specify the reference with one of `--tag`, `--branch`, or `--rev`, or use the `--raw-sources` flag.", name = requirement.name) .contains_key(&requirement.name);
} resolve_requirement(
Err(err) => return Err(err.into()), requirement,
}; workspace,
editable,
// Ignore the PEP 508 source. rev.clone(),
let mut requirement = pep508_rs::Requirement::from(requirement); tag.clone(),
requirement.clear_url(); branch.clone(),
)?
(requirement, source) }
}; };
// Update the `pyproject.toml`. // Update the `pyproject.toml`.
let edit = match dependency_type { let edit = match dependency_type {
DependencyType::Production => { DependencyType::Production => toml.add_dependency(&requirement, source.as_ref())?,
pyproject.add_dependency(&requirement, source.as_ref())? DependencyType::Dev => toml.add_dev_dependency(&requirement, source.as_ref())?,
}
DependencyType::Dev => pyproject.add_dev_dependency(&requirement, source.as_ref())?,
DependencyType::Optional(ref group) => { DependencyType::Optional(ref group) => {
pyproject.add_optional_dependency(group, &requirement, source.as_ref())? toml.add_optional_dependency(group, &requirement, source.as_ref())?
} }
}; };
@ -246,15 +348,35 @@ pub(crate) async fn add(
}); });
} }
// Save the modified `pyproject.toml`. let content = toml.to_string();
let mut modified = false;
let content = pyproject.to_string(); // Save the modified `pyproject.toml` or script.
if content == existing.raw { let modified = match &target {
debug!("No changes to `pyproject.toml`; skipping update"); Target::Script(script, _) => {
} else { if content == script.metadata.raw {
fs_err::write(project.root().join("pyproject.toml"), &content)?; debug!("No changes to dependencies; skipping update");
modified = true; false
} } else {
script.write(&content).await?;
true
}
}
Target::Project(project, _) => {
if content == *project.pyproject_toml().raw {
debug!("No changes to dependencies; skipping update");
false
} else {
let pyproject_path = project.root().join("pyproject.toml");
fs_err::write(pyproject_path, &content)?;
true
}
}
};
// If `--script`, exit early. There's no reason to lock and sync.
let Target::Project(project, venv) = target else {
return Ok(ExitStatus::Success);
};
// If `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock` // If `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock`
// to exist at all. // to exist at all.
@ -262,6 +384,8 @@ pub(crate) async fn add(
return Ok(ExitStatus::Success); return Ok(ExitStatus::Success);
} }
let existing = project.pyproject_toml();
// Update the `pypackage.toml` in-memory. // Update the `pypackage.toml` in-memory.
let project = project let project = project
.clone() .clone()
@ -357,13 +481,13 @@ pub(crate) async fn add(
match edit.dependency_type { match edit.dependency_type {
DependencyType::Production => { DependencyType::Production => {
pyproject.set_dependency_minimum_version(*index, minimum)?; toml.set_dependency_minimum_version(*index, minimum)?;
} }
DependencyType::Dev => { DependencyType::Dev => {
pyproject.set_dev_dependency_minimum_version(*index, minimum)?; toml.set_dev_dependency_minimum_version(*index, minimum)?;
} }
DependencyType::Optional(ref group) => { DependencyType::Optional(ref group) => {
pyproject.set_optional_dependency_minimum_version(group, *index, minimum)?; toml.set_optional_dependency_minimum_version(group, *index, minimum)?;
} }
} }
@ -374,7 +498,7 @@ pub(crate) async fn add(
// string content, since the above loop _must_ change an empty specifier to a non-empty // string content, since the above loop _must_ change an empty specifier to a non-empty
// specifier. // specifier.
if modified { if modified {
fs_err::write(project.root().join("pyproject.toml"), pyproject.to_string())?; fs_err::write(project.root().join("pyproject.toml"), toml.to_string())?;
} }
} }
@ -426,6 +550,62 @@ pub(crate) async fn add(
Ok(ExitStatus::Success) Ok(ExitStatus::Success)
} }
/// Resolves the source for a requirement and processes it into a PEP 508 compliant format.
fn resolve_requirement(
requirement: pypi_types::Requirement,
workspace: bool,
editable: Option<bool>,
rev: Option<String>,
tag: Option<String>,
branch: Option<String>,
) -> Result<(Requirement, Option<Source>), anyhow::Error> {
let result = Source::from_requirement(
&requirement.name,
requirement.source.clone(),
workspace,
editable,
rev,
tag,
branch,
);
let source = match result {
Ok(source) => source,
Err(SourceError::UnresolvedReference(rev)) => {
anyhow::bail!(
"Cannot resolve Git reference `{rev}` for requirement `{name}`. Specify the reference with one of `--tag`, `--branch`, or `--rev`, or use the `--raw-sources` flag.",
name = requirement.name
)
}
Err(err) => return Err(err.into()),
};
// Ignore the PEP 508 source by clearing the URL.
let mut processed_requirement = pep508_rs::Requirement::from(requirement);
processed_requirement.clear_url();
Ok((processed_requirement, source))
}
/// Represents the destination where dependencies are added, either to a project or a script.
#[derive(Debug)]
enum Target {
/// A PEP 723 script, with inline metadata.
Script(Pep723Script, Box<Interpreter>),
/// A project with a `pyproject.toml`.
Project(VirtualProject, PythonEnvironment),
}
impl Target {
/// Returns the [`Interpreter`] for the target.
fn interpreter(&self) -> &Interpreter {
match self {
Self::Script(_, interpreter) => interpreter,
Self::Project(_, venv) => venv.interpreter(),
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct DependencyEdit<'a> { struct DependencyEdit<'a> {
dependency_type: &'a DependencyType, dependency_type: &'a DependencyType,

View file

@ -16,7 +16,7 @@ use uv_python::{
}; };
use uv_resolver::RequiresPython; use uv_resolver::RequiresPython;
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
use uv_workspace::pyproject_mut::PyProjectTomlMut; use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
use uv_workspace::{check_nested_workspaces, DiscoveryOptions, Workspace, WorkspaceError}; use uv_workspace::{check_nested_workspaces, DiscoveryOptions, Workspace, WorkspaceError};
use crate::commands::project::find_requires_python; use crate::commands::project::find_requires_python;
@ -315,7 +315,10 @@ async fn init_project(
)?; )?;
} else { } else {
// Add the package to the workspace. // Add the package to the workspace.
let mut pyproject = PyProjectTomlMut::from_toml(workspace.pyproject_toml())?; let mut pyproject = PyProjectTomlMut::from_toml(
&workspace.pyproject_toml().raw,
DependencyTarget::PyProjectToml,
)?;
pyproject.add_workspace(path.strip_prefix(workspace.install_path())?)?; pyproject.add_workspace(path.strip_prefix(workspace.install_path())?)?;
// Save the modified `pyproject.toml`. // Save the modified `pyproject.toml`.

View file

@ -6,10 +6,11 @@ use uv_client::Connectivity;
use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode}; use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode};
use uv_fs::CWD; use uv_fs::CWD;
use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest};
use uv_scripts::Pep723Script;
use uv_warnings::{warn_user, warn_user_once}; use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::pyproject::DependencyType; use uv_workspace::pyproject::DependencyType;
use uv_workspace::pyproject_mut::PyProjectTomlMut; use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
use uv_workspace::{DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace}; use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace};
use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger};
use crate::commands::pip::operations::Modifications; use crate::commands::pip::operations::Modifications;
@ -28,6 +29,7 @@ pub(crate) async fn remove(
package: Option<PackageName>, package: Option<PackageName>,
python: Option<String>, python: Option<String>,
settings: ResolverInstallerSettings, settings: ResolverInstallerSettings,
script: Option<Pep723Script>,
python_preference: PythonPreference, python_preference: PythonPreference,
python_downloads: PythonDownloads, python_downloads: PythonDownloads,
preview: PreviewMode, preview: PreviewMode,
@ -41,41 +43,79 @@ pub(crate) async fn remove(
warn_user_once!("`uv remove` is experimental and may change without warning"); warn_user_once!("`uv remove` is experimental and may change without warning");
} }
// Find the project in the workspace. let target = if let Some(script) = script {
let project = if let Some(package) = package { // If we found a PEP 723 script and the user provided a project-only setting, warn.
Workspace::discover(&CWD, &DiscoveryOptions::default()) if package.is_some() {
.await? warn_user_once!(
.with_current_project(package.clone()) "`--package` is a no-op for Python scripts with inline metadata, which always run in isolation"
.with_context(|| format!("Package `{package}` not found in workspace"))? );
}
if locked {
warn_user_once!(
"`--locked` is a no-op for Python scripts with inline metadata, which always run in isolation"
);
}
if frozen {
warn_user_once!(
"`--frozen` is a no-op for Python scripts with inline metadata, which always run in isolation"
);
}
if no_sync {
warn_user_once!(
"`--no_sync` is a no-op for Python scripts with inline metadata, which always run in isolation"
);
}
Target::Script(script)
} else { } else {
ProjectWorkspace::discover(&CWD, &DiscoveryOptions::default()).await? // Find the project in the workspace.
let project = if let Some(package) = package {
VirtualProject::Project(
Workspace::discover(&CWD, &DiscoveryOptions::default())
.await?
.with_current_project(package.clone())
.with_context(|| format!("Package `{package}` not found in workspace"))?,
)
} else {
VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await?
};
Target::Project(project)
}; };
let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; let mut toml = match &target {
Target::Script(script) => {
PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script)
}
Target::Project(project) => PyProjectTomlMut::from_toml(
project.pyproject_toml().raw.as_ref(),
DependencyTarget::PyProjectToml,
),
}?;
for package in packages { for package in packages {
match dependency_type { match dependency_type {
DependencyType::Production => { DependencyType::Production => {
let deps = pyproject.remove_dependency(&package)?; let deps = toml.remove_dependency(&package)?;
if deps.is_empty() { if deps.is_empty() {
warn_if_present(&package, &pyproject); warn_if_present(&package, &toml);
anyhow::bail!( anyhow::bail!(
"The dependency `{package}` could not be found in `dependencies`" "The dependency `{package}` could not be found in `dependencies`"
); );
} }
} }
DependencyType::Dev => { DependencyType::Dev => {
let deps = pyproject.remove_dev_dependency(&package)?; let deps = toml.remove_dev_dependency(&package)?;
if deps.is_empty() { if deps.is_empty() {
warn_if_present(&package, &pyproject); warn_if_present(&package, &toml);
anyhow::bail!( anyhow::bail!(
"The dependency `{package}` could not be found in `dev-dependencies`" "The dependency `{package}` could not be found in `dev-dependencies`"
); );
} }
} }
DependencyType::Optional(ref group) => { DependencyType::Optional(ref group) => {
let deps = pyproject.remove_optional_dependency(&package, group)?; let deps = toml.remove_optional_dependency(&package, group)?;
if deps.is_empty() { if deps.is_empty() {
warn_if_present(&package, &pyproject); warn_if_present(&package, &toml);
anyhow::bail!( anyhow::bail!(
"The dependency `{package}` could not be found in `optional-dependencies`" "The dependency `{package}` could not be found in `optional-dependencies`"
); );
@ -84,11 +124,16 @@ pub(crate) async fn remove(
} }
} }
// Save the modified `pyproject.toml`. // Save the modified dependencies.
fs_err::write( match &target {
project.current_project().root().join("pyproject.toml"), Target::Script(script) => {
pyproject.to_string(), script.write(&toml.to_string()).await?;
)?; }
Target::Project(project) => {
let pyproject_path = project.root().join("pyproject.toml");
fs_err::write(pyproject_path, toml.to_string())?;
}
};
// If `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock` // If `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock`
// to exist at all. // to exist at all.
@ -96,6 +141,11 @@ pub(crate) async fn remove(
return Ok(ExitStatus::Success); return Ok(ExitStatus::Success);
} }
// If `--script`, exit early. There's no reason to lock and sync.
let Target::Project(project) = target else {
return Ok(ExitStatus::Success);
};
// Discover or create the virtual environment. // Discover or create the virtual environment.
let venv = project::get_or_init_environment( let venv = project::get_or_init_environment(
project.workspace(), project.workspace(),
@ -139,7 +189,7 @@ pub(crate) async fn remove(
let state = SharedState::default(); let state = SharedState::default();
project::sync::do_sync( project::sync::do_sync(
&VirtualProject::Project(project), &project,
&venv, &venv,
&lock.lock, &lock.lock,
&extras, &extras,
@ -160,6 +210,15 @@ pub(crate) async fn remove(
Ok(ExitStatus::Success) Ok(ExitStatus::Success)
} }
/// Represents the destination where dependencies are added, either to a project or a script.
#[derive(Debug)]
enum Target {
/// A PEP 723 script, with inline metadata.
Project(VirtualProject),
/// A project with a `pyproject.toml`.
Script(Pep723Script),
}
/// Emit a warning if a dependency with the given name is present as any dependency type. /// Emit a warning if a dependency with the given name is present as any dependency type.
/// ///
/// This is useful when a dependency of the user-specified type was not found, but it may be present /// This is useful when a dependency of the user-specified type was not found, but it may be present

View file

@ -135,6 +135,12 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
let script = if let Commands::Project(command) = &*cli.command { let script = if let Commands::Project(command) = &*cli.command {
if let ProjectCommand::Run(uv_cli::RunArgs { command, .. }) = &**command { if let ProjectCommand::Run(uv_cli::RunArgs { command, .. }) = &**command {
parse_script(command).await? parse_script(command).await?
} else if let ProjectCommand::Remove(uv_cli::RemoveArgs {
script: Some(script),
..
}) = &**command
{
Pep723Script::read(&script).await?
} else { } else {
None None
} }
@ -1157,6 +1163,7 @@ async fn run_project(
args.package, args.package,
args.python, args.python,
args.settings, args.settings,
args.script,
globals.python_preference, globals.python_preference,
globals.python_downloads, globals.python_downloads,
globals.preview, globals.preview,
@ -1189,6 +1196,7 @@ async fn run_project(
args.package, args.package,
args.python, args.python,
args.settings, args.settings,
script,
globals.python_preference, globals.python_preference,
globals.python_downloads, globals.python_downloads,
globals.preview, globals.preview,

View file

@ -703,6 +703,7 @@ pub(crate) struct AddSettings {
pub(crate) tag: Option<String>, pub(crate) tag: Option<String>,
pub(crate) branch: Option<String>, pub(crate) branch: Option<String>,
pub(crate) package: Option<PackageName>, pub(crate) package: Option<PackageName>,
pub(crate) script: Option<PathBuf>,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) refresh: Refresh, pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings, pub(crate) settings: ResolverInstallerSettings,
@ -730,6 +731,7 @@ impl AddSettings {
build, build,
refresh, refresh,
package, package,
script,
python, python,
} = args; } = args;
@ -757,6 +759,7 @@ impl AddSettings {
tag, tag,
branch, branch,
package, package,
script,
python, python,
editable: flag(editable, no_editable), editable: flag(editable, no_editable),
extras: extra.unwrap_or_default(), extras: extra.unwrap_or_default(),
@ -779,6 +782,7 @@ pub(crate) struct RemoveSettings {
pub(crate) packages: Vec<PackageName>, pub(crate) packages: Vec<PackageName>,
pub(crate) dependency_type: DependencyType, pub(crate) dependency_type: DependencyType,
pub(crate) package: Option<PackageName>, pub(crate) package: Option<PackageName>,
pub(crate) script: Option<PathBuf>,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) refresh: Refresh, pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings, pub(crate) settings: ResolverInstallerSettings,
@ -799,6 +803,7 @@ impl RemoveSettings {
build, build,
refresh, refresh,
package, package,
script,
python, python,
} = args; } = args;
@ -817,6 +822,7 @@ impl RemoveSettings {
packages, packages,
dependency_type, dependency_type,
package, package,
script,
python, python,
refresh: Refresh::from(refresh), refresh: Refresh::from(refresh),
settings: ResolverInstallerSettings::combine( settings: ResolverInstallerSettings::combine(

View file

@ -2912,3 +2912,412 @@ fn add_repeat() -> Result<()> {
Ok(()) Ok(())
} }
/// Add to a PEP 732 script.
#[test]
fn add_script() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?;
uv_snapshot!(context.filters(), context.add(&["anyio"]).arg("--script").arg(script.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv add` is experimental and may change without warning
"###);
let script_content = fs_err::read_to_string(context.temp_dir.join("script.py"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
script_content, @r###"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# "anyio",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"###
);
});
Ok(())
}
/// Add to a script without an existing metadata table.
#[test]
fn add_script_without_metadata_table() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?;
uv_snapshot!(context.filters(), context.add(&["rich", "requests<3"]).arg("--script").arg(script.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv add` is experimental and may change without warning
"###);
let script_content = fs_err::read_to_string(context.temp_dir.join("script.py"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
script_content, @r###"
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "rich",
# "requests<3",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"###
);
});
Ok(())
}
/// Add to a script without an existing metadata table, but with a shebang.
#[test]
fn add_script_without_metadata_table_with_shebang() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
#!/usr/bin/env python3
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?;
uv_snapshot!(context.filters(), context.add(&["rich", "requests<3"]).arg("--script").arg(script.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv add` is experimental and may change without warning
"###);
let script_content = fs_err::read_to_string(context.temp_dir.join("script.py"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
script_content, @r###"
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "rich",
# "requests<3",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"###
);
});
Ok(())
}
/// Add to a script without a metadata table, but with a docstring.
#[test]
fn add_script_without_metadata_table_with_docstring() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
"""This is a script."""
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?;
uv_snapshot!(context.filters(), context.add(&["rich", "requests<3"]).arg("--script").arg(script.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv add` is experimental and may change without warning
"###);
let script_content = fs_err::read_to_string(context.temp_dir.join("script.py"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
script_content, @r###"
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "rich",
# "requests<3",
# ]
# ///
"""This is a script."""
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"###
);
});
Ok(())
}
/// Remove from a PEP732 script,
#[test]
fn remove_script() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# "anyio",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?;
uv_snapshot!(context.filters(), context.remove(&["anyio"]).arg("--script").arg(script.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv remove` is experimental and may change without warning
"###);
let script_content = fs_err::read_to_string(context.temp_dir.join("script.py"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
script_content, @r###"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"###
);
});
Ok(())
}
/// Remove last dependency PEP732 script
#[test]
fn remove_last_dep_script() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?;
uv_snapshot!(context.filters(), context.remove(&["rich"]).arg("--script").arg(script.path()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv remove` is experimental and may change without warning
"###);
let script_content = fs_err::read_to_string(context.temp_dir.join("script.py"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
script_content, @r###"
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"###
);
});
Ok(())
}
/// Add a Git requirement to PEP732 script.
#[test]
#[cfg(feature = "git")]
fn add_git_to_script() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"#})?;
// Adding with an ambiguous Git reference will fail.
uv_snapshot!(context.filters(), context
.add(&["uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage@0.0.1"])
.arg("--preview")
.arg("--script")
.arg("script.py"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Cannot resolve Git reference `0.0.1` for requirement `uv-public-pypackage`. Specify the reference with one of `--tag`, `--branch`, or `--rev`, or use the `--raw-sources` flag.
"###);
uv_snapshot!(context.filters(), context
.add(&["uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage"])
.arg("--tag=0.0.1")
.arg("--preview")
.arg("--script")
.arg("script.py"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
"###);
let script_content = fs_err::read_to_string(context.temp_dir.join("script.py"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
script_content, @r###"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich",
# "uv-public-pypackage",
# ]
# [tool.uv.sources]
# uv-public-pypackage = { git = "https://github.com/astral-test/uv-public-pypackage", tag = "0.0.1" }
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"###
);
});
Ok(())
}

View file

@ -711,6 +711,10 @@ uv add [OPTIONS] <REQUIREMENTS>...
</ul> </ul>
</dd><dt><code>--rev</code> <i>rev</i></dt><dd><p>Commit to use when adding a dependency from Git</p> </dd><dt><code>--rev</code> <i>rev</i></dt><dd><p>Commit to use when adding a dependency from Git</p>
</dd><dt><code>--script</code> <i>script</i></dt><dd><p>Add the dependency to the specified Python script, rather than to a project.</p>
<p>If provided, uv will add the dependency to the script&#8217;s inline metadata table, in adhere with PEP 723. If no such inline metadata table is present, a new one will be created and added to the script. When executed via <code>uv run</code>, uv will create a temporary environment for the script with all inline dependencies installed.</p>
</dd><dt><code>--tag</code> <i>tag</i></dt><dd><p>Tag to use when adding a dependency from Git</p> </dd><dt><code>--tag</code> <i>tag</i></dt><dd><p>Tag to use when adding a dependency from Git</p>
</dd><dt><code>--upgrade</code>, <code>-U</code></dt><dd><p>Allow package upgrades, ignoring pinned versions in any existing output file. Implies <code>--refresh</code></p> </dd><dt><code>--upgrade</code>, <code>-U</code></dt><dd><p>Allow package upgrades, ignoring pinned versions in any existing output file. Implies <code>--refresh</code></p>
@ -967,6 +971,10 @@ uv remove [OPTIONS] <PACKAGES>...
<li><code>lowest-direct</code>: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies</li> <li><code>lowest-direct</code>: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies</li>
</ul> </ul>
</dd><dt><code>--script</code> <i>script</i></dt><dd><p>Remove the dependency from the specified Python script, rather than from a project.</p>
<p>If provided, uv will remove the dependency from the script&#8217;s inline metadata table, in adhere with PEP 723.</p>
</dd><dt><code>--upgrade</code>, <code>-U</code></dt><dd><p>Allow package upgrades, ignoring pinned versions in any existing output file. Implies <code>--refresh</code></p> </dd><dt><code>--upgrade</code>, <code>-U</code></dt><dd><p>Allow package upgrades, ignoring pinned versions in any existing output file. Implies <code>--refresh</code></p>
</dd><dt><code>--upgrade-package</code>, <code>-P</code> <i>upgrade-package</i></dt><dd><p>Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies <code>--refresh-package</code></p> </dd><dt><code>--upgrade-package</code>, <code>-P</code> <i>upgrade-package</i></dt><dd><p>Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies <code>--refresh-package</code></p>