From 2d53e35e391f2934d3459d56ad5eff6bb650ac31 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Sun, 11 Aug 2024 03:40:59 +0200 Subject: [PATCH] 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` --- crates/uv-cli/src/lib.rs | 17 + crates/uv-scripts/Cargo.toml | 4 +- crates/uv-scripts/src/lib.rs | 497 ++++++++++++++++------- crates/uv-workspace/src/pyproject_mut.rs | 81 ++-- crates/uv-workspace/src/workspace.rs | 2 +- crates/uv/src/commands/project/add.rs | 362 ++++++++++++----- crates/uv/src/commands/project/init.rs | 7 +- crates/uv/src/commands/project/remove.rs | 103 ++++- crates/uv/src/lib.rs | 8 + crates/uv/src/settings.rs | 6 + crates/uv/tests/edit.rs | 409 +++++++++++++++++++ docs/reference/cli.md | 8 + 12 files changed, 1215 insertions(+), 289 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 7e5a7b5c3..1790d981c 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2450,6 +2450,16 @@ pub struct AddArgs { #[arg(long, conflicts_with = "isolated")] pub package: Option, + /// 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, + /// The Python interpreter to use for resolving and syncing. /// /// See `uv help python` for details on Python discovery and supported @@ -2509,6 +2519,13 @@ pub struct RemoveArgs { #[arg(long, conflicts_with = "isolated")] pub package: Option, + /// 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, + /// The Python interpreter to use for resolving and syncing. /// /// See `uv help python` for details on Python discovery and supported diff --git a/crates/uv-scripts/Cargo.toml b/crates/uv-scripts/Cargo.toml index ff8def78a..cf2265d49 100644 --- a/crates/uv-scripts/Cargo.toml +++ b/crates/uv-scripts/Cargo.toml @@ -15,10 +15,8 @@ uv-settings = { workspace = true } uv-workspace = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } +indoc = { workspace = true } memchr = { workspace = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } toml = { workspace = true } - -[dev-dependencies] -indoc = { workspace = true } diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index c2f66ac26..50c3a2121 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -1,9 +1,11 @@ use std::collections::BTreeMap; use std::io; use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::sync::LazyLock; use memchr::memmem::Finder; +use pep440_rs::VersionSpecifiers; use serde::Deserialize; use thiserror::Error; @@ -17,8 +19,14 @@ static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")) /// A PEP 723 script, including its [`Pep723Metadata`]. #[derive(Debug)] pub struct Pep723Script { + /// The path to the Python script. pub path: PathBuf, + /// The parsed [`Pep723Metadata`] table from the script. 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 { @@ -26,12 +34,76 @@ impl Pep723Script { /// /// See: pub async fn read(file: impl AsRef) -> Result, Pep723Error> { - let metadata = Pep723Metadata::read(&file).await?; - Ok(metadata.map(|metadata| Self { + 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. + 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(), metadata, + raw: script_tag.script, + prelude: script_tag.prelude, })) } + + /// Reads a Python script and generates a default PEP 723 metadata table. + /// + /// See: + pub async fn create( + file: impl AsRef, + requires_python: &VersionSpecifiers, + ) -> Result { + 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. @@ -41,30 +113,23 @@ impl Pep723Script { #[serde(rename_all = "kebab-case")] pub struct Pep723Metadata { pub dependencies: Option>>, - pub requires_python: Option, + pub requires_python: Option, pub tool: Option, + /// The raw unserialized document. + #[serde(skip)] + pub raw: String, } -impl Pep723Metadata { - /// Read the PEP 723 `script` metadata from a Python file, if it exists. - /// - /// See: - pub async fn read(file: impl AsRef) -> Result, 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()), - }; +impl FromStr for Pep723Metadata { + type Err = Pep723Error; - // 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)) + /// Parse `Pep723Metadata` from a raw TOML string. + fn from_str(raw: &str) -> Result { + let metadata = toml::from_str(raw)?; + Ok(Pep723Metadata { + raw: raw.to_string(), + ..metadata + }) } } @@ -94,120 +159,193 @@ pub enum Pep723Error { Toml(#[from] toml::de::Error), } -/// Read the PEP 723 `script` metadata from a Python file, if it exists. -/// -/// See: -pub async fn read_pep723_metadata( - file: impl AsRef, -) -> Result, 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. - let Some(contents) = extract_script_tag(&contents)? else { - return Ok(None); - }; - - // Parse the metadata. - let metadata = toml::from_str(&contents)?; - - Ok(Some(metadata)) +#[derive(Debug, Clone, Eq, PartialEq)] +struct ScriptTag { + /// The content of the script before the metadata block. + prelude: String, + /// The metadata block. + metadata: String, + /// The content of the script after the metadata block. + script: String, } -/// Given the contents of a Python file, extract the `script` metadata block, with leading comment -/// hashes removed. -/// -/// See: -fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { - // Identify the opening pragma. - let Some(index) = FINDER.find(contents) else { - return Ok(None); - }; +impl ScriptTag { + /// 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 + /// script. + /// + /// Given the following input string representing the contents of a Python script: + /// + /// ```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: + fn parse(contents: &[u8]) -> Result, 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. - if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) { - return Ok(None); + // 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')) { + 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. - let contents = &contents[index..]; +/// Extracts the shebang line from the given file contents and returns it along with the remaining +/// content. +fn extract_shebang(contents: &[u8]) -> Result<(Option, String), Pep723Error> { 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![]; - 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; + // Check the first line for a shebang + if let Some(first_line) = lines.next() { + if first_line.starts_with("#!") { + let shebang = first_line.to_string(); + let remaining_content: String = lines.collect::>().join("\n"); + return Ok((Some(shebang), remaining_content)); } - - // 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 - // 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; + Ok((None, contents.to_string())) +} - // Discard any lines after the closing `# ///`. - // - // For example, given: - // ```python - // # /// script - // # - // # /// - // # - // # - // ``` - // - // We need to discard the last two lines. - toml.truncate(index - 1); +/// Formats the provided metadata by prefixing each line with `#` and wrapping it with script markers. +fn serialize_metadata(metadata: &str) -> String { + let mut output = String::with_capacity(metadata.len() + 2); - // Join the lines into a single string. - let toml = toml.join("\n") + "\n"; + output.push_str("# /// script\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)] mod tests { + use crate::{serialize_metadata, ScriptTag}; + #[test] fn missing_space() { let contents = indoc::indoc! {r" @@ -216,10 +354,7 @@ mod tests { # /// "}; - assert_eq!( - super::extract_script_tag(contents.as_bytes()).unwrap(), - None - ); + assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None); } #[test] @@ -233,10 +368,7 @@ mod tests { # ] "}; - assert_eq!( - super::extract_script_tag(contents.as_bytes()).unwrap(), - None - ); + assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None); } #[test] @@ -253,10 +385,7 @@ mod tests { # "}; - assert_eq!( - super::extract_script_tag(contents.as_bytes()).unwrap(), - None - ); + assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None); } #[test] @@ -269,9 +398,15 @@ mod tests { # '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' dependencies = [ 'requests<3', @@ -279,13 +414,64 @@ mod tests { ] "}; - let actual = super::extract_script_tag(contents.as_bytes()) - .unwrap() - .unwrap(); + let expected_data = indoc::indoc! {r" - 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] fn embedded_comment() { 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() + .metadata; 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() + .metadata; 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); + } } diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 23ad59a8f..3e083f55a 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -8,7 +8,7 @@ use thiserror::Error; use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; use uv_fs::PortablePath; -use crate::pyproject::{DependencyType, PyProjectToml, Source}; +use crate::pyproject::{DependencyType, Source}; /// 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`. pub struct PyProjectTomlMut { doc: DocumentMut, + target: DependencyTarget, } #[derive(Error, Debug)] @@ -47,11 +48,21 @@ pub enum ArrayEdit { 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 { - /// Initialize a [`PyProjectTomlMut`] from a [`PyProjectToml`]. - pub fn from_toml(pyproject: &PyProjectToml) -> Result { + /// Initialize a [`PyProjectTomlMut`] from a [`str`]. + pub fn from_toml(raw: &str, target: DependencyTarget) -> Result { 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(()) } + /// 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, 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`. /// /// Returns `true` if the dependency was added, `false` if it was updated. @@ -93,11 +132,7 @@ impl PyProjectTomlMut { ) -> Result { // Get or create `project.dependencies`. let dependencies = self - .doc - .entry("project") - .or_insert(Item::Table(Table::new())) - .as_table_mut() - .ok_or(Error::MalformedDependencies)? + .doc()? .entry("dependencies") .or_insert(Item::Value(Value::Array(Array::new()))) .as_array_mut() @@ -158,11 +193,7 @@ impl PyProjectTomlMut { ) -> Result { // Get or create `project.optional-dependencies`. let optional_dependencies = self - .doc - .entry("project") - .or_insert(Item::Table(Table::new())) - .as_table_mut() - .ok_or(Error::MalformedDependencies)? + .doc()? .entry("optional-dependencies") .or_insert(Item::Table(Table::new())) .as_table_mut() @@ -192,11 +223,7 @@ impl PyProjectTomlMut { ) -> Result<(), Error> { // Get or create `project.dependencies`. let dependencies = self - .doc - .entry("project") - .or_insert(Item::Table(Table::new())) - .as_table_mut() - .ok_or(Error::MalformedDependencies)? + .doc()? .entry("dependencies") .or_insert(Item::Value(Value::Array(Array::new()))) .as_array_mut() @@ -265,11 +292,7 @@ impl PyProjectTomlMut { ) -> Result<(), Error> { // Get or create `project.optional-dependencies`. let optional_dependencies = self - .doc - .entry("project") - .or_insert(Item::Table(Table::new())) - .as_table_mut() - .ok_or(Error::MalformedDependencies)? + .doc()? .entry("optional-dependencies") .or_insert(Item::Table(Table::new())) .as_table_mut() @@ -323,10 +346,7 @@ impl PyProjectTomlMut { pub fn remove_dependency(&mut self, req: &PackageName) -> Result, Error> { // Try to get `project.dependencies`. let Some(dependencies) = self - .doc - .get_mut("project") - .map(|project| project.as_table_mut().ok_or(Error::MalformedSources)) - .transpose()? + .doc_mut()? .and_then(|project| project.get_mut("dependencies")) .map(|dependencies| dependencies.as_array_mut().ok_or(Error::MalformedSources)) .transpose()? @@ -372,10 +392,7 @@ impl PyProjectTomlMut { ) -> Result, Error> { // Try to get `project.optional-dependencies.`. let Some(optional_dependencies) = self - .doc - .get_mut("project") - .map(|project| project.as_table_mut().ok_or(Error::MalformedSources)) - .transpose()? + .doc_mut()? .and_then(|project| project.get_mut("optional-dependencies")) .map(|extras| extras.as_table_mut().ok_or(Error::MalformedSources)) .transpose()? diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 38b5de75c..7e4097df9 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -104,7 +104,7 @@ impl Workspace { let pyproject_path = project_path.join("pyproject.toml"); 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)))?; // Check if the project is explicitly marked as unmanaged. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 30d9a3eaf..aa43a5abf 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -1,10 +1,12 @@ use std::collections::hash_map::Entry; +use std::path::PathBuf; use anyhow::{Context, Result}; use owo_colors::OwoColorize; -use pep508_rs::{ExtraName, Requirement, VersionOrUrl}; use rustc_hash::{FxBuildHasher, FxHashMap}; use tracing::debug; + +use pep508_rs::{ExtraName, Requirement, VersionOrUrl}; use uv_auth::store_credentials_from_url; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; @@ -15,20 +17,24 @@ use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; use uv_fs::CWD; 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_resolver::FlatIndex; +use uv_resolver::{FlatIndex, RequiresPython}; +use uv_scripts::Pep723Script; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; 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 crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::operations::Modifications; use crate::commands::pip::resolution_environment; 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::printer::Printer; use crate::settings::ResolverInstallerSettings; @@ -50,6 +56,7 @@ pub(crate) async fn add( package: Option, python: Option, settings: ResolverInstallerSettings, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, preview: PreviewMode, @@ -63,43 +70,139 @@ pub(crate) async fn add( warn_user_once!("`uv add` is experimental and may change without warning"); } - // 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? - }; + let reporter = PythonDownloadReporter::single(printer); - // 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 => (), + let target = if let Some(script) = script { + // If we found a PEP 723 script and the user provided a project-only setting, warn. + if package.is_some() { + warn_user_once!( + "`--package` is a no-op for Python scripts with inline metadata, which always run in isolation" + ); + } + 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" + ); } - } - // 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?; + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); + + // If we found a script, add to the existing metadata. Otherwise, create a new inline + // metadata tag. + let script = if let Some(script) = Pep723Script::read(&script).await? { + script + } else { + let python_request = if let Some(request) = python.as_deref() { + // (1) Explicit request from user + 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() .connectivity(connectivity) @@ -120,7 +223,7 @@ pub(crate) async fn add( // Determine the environment for the resolution. 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. for url in settings.index_locations.urls() { @@ -132,7 +235,7 @@ pub(crate) async fn add( .index_urls(settings.index_locations.index_urls()) .index_strategy(settings.index_strategy) .markers(&markers) - .platform(venv.interpreter().platform()) + .platform(target.interpreter().platform()) .build(); // Initialize any shared state. @@ -153,7 +256,7 @@ pub(crate) async fn add( &client, cache, &build_constraints, - venv.interpreter(), + target.interpreter(), &settings.index_locations, &flat_index, &state.index, @@ -182,9 +285,16 @@ pub(crate) async fn add( .resolve() .await?; - // Add the requirements to the `pyproject.toml`. - let existing = project.pyproject_toml(); - let mut pyproject = PyProjectTomlMut::from_toml(existing)?; + // Add the requirements to the `pyproject.toml` or script. + 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, + DependencyTarget::PyProjectToml, + ), + }?; let mut edits = Vec::with_capacity(requirements.len()); for mut requirement in requirements { // Add the specified extras. @@ -192,48 +302,40 @@ pub(crate) async fn add( requirement.extras.sort_unstable(); requirement.extras.dedup(); - let (requirement, source) = if raw_sources { - // Use the PEP 508 requirement directly. - (pep508_rs::Requirement::from(requirement), None) - } else { - // Otherwise, try to construct the source. - let workspace = project - .workspace() - .packages() - .contains_key(&requirement.name); - let result = Source::from_requirement( - &requirement.name, - requirement.source.clone(), - workspace, + let (requirement, source) = match target { + Target::Script(_, _) | Target::Project(_, _) if raw_sources => { + (pep508_rs::Requirement::from(requirement), None) + } + Target::Script(_, _) => resolve_requirement( + requirement, + false, editable, rev.clone(), tag.clone(), branch.clone(), - ); - - 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. - let mut requirement = pep508_rs::Requirement::from(requirement); - requirement.clear_url(); - - (requirement, source) + )?, + Target::Project(ref project, _) => { + let workspace = project + .workspace() + .packages() + .contains_key(&requirement.name); + resolve_requirement( + requirement, + workspace, + editable, + rev.clone(), + tag.clone(), + branch.clone(), + )? + } }; // Update the `pyproject.toml`. let edit = match dependency_type { - DependencyType::Production => { - pyproject.add_dependency(&requirement, source.as_ref())? - } - DependencyType::Dev => pyproject.add_dev_dependency(&requirement, source.as_ref())?, + DependencyType::Production => toml.add_dependency(&requirement, source.as_ref())?, + DependencyType::Dev => toml.add_dev_dependency(&requirement, source.as_ref())?, 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 mut modified = false; - let content = pyproject.to_string(); - if content == existing.raw { - debug!("No changes to `pyproject.toml`; skipping update"); - } else { - fs_err::write(project.root().join("pyproject.toml"), &content)?; - modified = true; - } + let content = toml.to_string(); + + // Save the modified `pyproject.toml` or script. + let modified = match &target { + Target::Script(script, _) => { + if content == script.metadata.raw { + debug!("No changes to dependencies; skipping update"); + 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` // to exist at all. @@ -262,6 +384,8 @@ pub(crate) async fn add( return Ok(ExitStatus::Success); } + let existing = project.pyproject_toml(); + // Update the `pypackage.toml` in-memory. let project = project .clone() @@ -357,13 +481,13 @@ pub(crate) async fn add( match edit.dependency_type { DependencyType::Production => { - pyproject.set_dependency_minimum_version(*index, minimum)?; + toml.set_dependency_minimum_version(*index, minimum)?; } DependencyType::Dev => { - pyproject.set_dev_dependency_minimum_version(*index, minimum)?; + toml.set_dev_dependency_minimum_version(*index, minimum)?; } 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 // specifier. 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) } +/// 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, + rev: Option, + tag: Option, + branch: Option, +) -> Result<(Requirement, Option), 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), + /// 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)] struct DependencyEdit<'a> { dependency_type: &'a DependencyType, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index abe75f686..c469346fd 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -16,7 +16,7 @@ use uv_python::{ }; use uv_resolver::RequiresPython; 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 crate::commands::project::find_requires_python; @@ -315,7 +315,10 @@ async fn init_project( )?; } else { // 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())?)?; // Save the modified `pyproject.toml`. diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index c0129ec6e..a8641edba 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -6,10 +6,11 @@ use uv_client::Connectivity; use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode}; use uv_fs::CWD; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; +use uv_scripts::Pep723Script; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::pyproject::DependencyType; -use uv_workspace::pyproject_mut::PyProjectTomlMut; -use uv_workspace::{DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace}; +use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut}; +use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace}; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::operations::Modifications; @@ -28,6 +29,7 @@ pub(crate) async fn remove( package: Option, python: Option, settings: ResolverInstallerSettings, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, preview: PreviewMode, @@ -41,41 +43,79 @@ pub(crate) async fn remove( warn_user_once!("`uv remove` is experimental and may change without warning"); } - // Find the project in the workspace. - let project = if let Some(package) = package { - Workspace::discover(&CWD, &DiscoveryOptions::default()) - .await? - .with_current_project(package.clone()) - .with_context(|| format!("Package `{package}` not found in workspace"))? + let target = if let Some(script) = script { + // If we found a PEP 723 script and the user provided a project-only setting, warn. + if package.is_some() { + warn_user_once!( + "`--package` is a no-op for Python scripts with inline metadata, which always run in isolation" + ); + } + 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 { - 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 { match dependency_type { DependencyType::Production => { - let deps = pyproject.remove_dependency(&package)?; + let deps = toml.remove_dependency(&package)?; if deps.is_empty() { - warn_if_present(&package, &pyproject); + warn_if_present(&package, &toml); anyhow::bail!( "The dependency `{package}` could not be found in `dependencies`" ); } } DependencyType::Dev => { - let deps = pyproject.remove_dev_dependency(&package)?; + let deps = toml.remove_dev_dependency(&package)?; if deps.is_empty() { - warn_if_present(&package, &pyproject); + warn_if_present(&package, &toml); anyhow::bail!( "The dependency `{package}` could not be found in `dev-dependencies`" ); } } DependencyType::Optional(ref group) => { - let deps = pyproject.remove_optional_dependency(&package, group)?; + let deps = toml.remove_optional_dependency(&package, group)?; if deps.is_empty() { - warn_if_present(&package, &pyproject); + warn_if_present(&package, &toml); anyhow::bail!( "The dependency `{package}` could not be found in `optional-dependencies`" ); @@ -84,11 +124,16 @@ pub(crate) async fn remove( } } - // Save the modified `pyproject.toml`. - fs_err::write( - project.current_project().root().join("pyproject.toml"), - pyproject.to_string(), - )?; + // Save the modified dependencies. + match &target { + Target::Script(script) => { + 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` // to exist at all. @@ -96,6 +141,11 @@ pub(crate) async fn remove( 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. let venv = project::get_or_init_environment( project.workspace(), @@ -139,7 +189,7 @@ pub(crate) async fn remove( let state = SharedState::default(); project::sync::do_sync( - &VirtualProject::Project(project), + &project, &venv, &lock.lock, &extras, @@ -160,6 +210,15 @@ pub(crate) async fn remove( 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. /// /// This is useful when a dependency of the user-specified type was not found, but it may be present diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c5f093cf0..466b0943e 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -135,6 +135,12 @@ async fn run(cli: Cli) -> Result { let script = if let Commands::Project(command) = &*cli.command { if let ProjectCommand::Run(uv_cli::RunArgs { command, .. }) = &**command { parse_script(command).await? + } else if let ProjectCommand::Remove(uv_cli::RemoveArgs { + script: Some(script), + .. + }) = &**command + { + Pep723Script::read(&script).await? } else { None } @@ -1157,6 +1163,7 @@ async fn run_project( args.package, args.python, args.settings, + args.script, globals.python_preference, globals.python_downloads, globals.preview, @@ -1189,6 +1196,7 @@ async fn run_project( args.package, args.python, args.settings, + script, globals.python_preference, globals.python_downloads, globals.preview, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index be54b1de8..10da1ee37 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -703,6 +703,7 @@ pub(crate) struct AddSettings { pub(crate) tag: Option, pub(crate) branch: Option, pub(crate) package: Option, + pub(crate) script: Option, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, @@ -730,6 +731,7 @@ impl AddSettings { build, refresh, package, + script, python, } = args; @@ -757,6 +759,7 @@ impl AddSettings { tag, branch, package, + script, python, editable: flag(editable, no_editable), extras: extra.unwrap_or_default(), @@ -779,6 +782,7 @@ pub(crate) struct RemoveSettings { pub(crate) packages: Vec, pub(crate) dependency_type: DependencyType, pub(crate) package: Option, + pub(crate) script: Option, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, @@ -799,6 +803,7 @@ impl RemoveSettings { build, refresh, package, + script, python, } = args; @@ -817,6 +822,7 @@ impl RemoveSettings { packages, dependency_type, package, + script, python, refresh: Refresh::from(refresh), settings: ResolverInstallerSettings::combine( diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index a11ef0d9e..061dac21e 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2912,3 +2912,412 @@ fn add_repeat() -> Result<()> { 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(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 284031ba0..d7387221c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -711,6 +711,10 @@ uv add [OPTIONS] ...
--rev rev

Commit to use when adding a dependency from Git

+
--script script

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.

+
--tag tag

Tag to use when adding a dependency from Git

--upgrade, -U

Allow package upgrades, ignoring pinned versions in any existing output file. Implies --refresh

@@ -967,6 +971,10 @@ uv remove [OPTIONS] ...
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
    --script script

    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.

    +
    --upgrade, -U

    Allow package upgrades, ignoring pinned versions in any existing output file. Implies --refresh

    --upgrade-package, -P upgrade-package

    Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies --refresh-package