From 6f7dd38059df7e22ff863e34fe5cc641dab04503 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Tue, 2 Jul 2024 16:20:23 +0200 Subject: [PATCH 01/10] Support PEP 723 scripts in `uv add` and `uv remove` --- crates/uv-cli/src/lib.rs | 7 + crates/uv-scripts/src/lib.rs | 177 +++++++++++----- crates/uv-workspace/src/pyproject_mut.rs | 77 ++++--- crates/uv-workspace/src/workspace.rs | 2 +- crates/uv/src/commands/project/add.rs | 257 +++++++++++++++-------- crates/uv/src/commands/project/init.rs | 7 +- crates/uv/src/commands/project/remove.rs | 79 +++++-- crates/uv/src/lib.rs | 14 ++ crates/uv/src/settings.rs | 6 + crates/uv/tests/edit.rs | 181 ++++++++++++++++ docs/reference/cli.md | 4 + 11 files changed, 616 insertions(+), 195 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 7e5a7b5c3462..77360190fd2d 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2462,6 +2462,10 @@ pub struct AddArgs { help_heading = "Python options" )] pub python: Option, + + /// Specifies the Python script where the dependency will be added. + #[arg(long)] + pub script: Option, } #[derive(Args)] @@ -2509,6 +2513,9 @@ pub struct RemoveArgs { #[arg(long, conflicts_with = "isolated")] pub package: Option, + /// Specifies the Python script where the dependency will be removed. + #[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/src/lib.rs b/crates/uv-scripts/src/lib.rs index c2f66ac26ea4..2359829fa877 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -19,6 +19,7 @@ static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")) pub struct Pep723Script { pub path: PathBuf, pub metadata: Pep723Metadata, + pub data: String, } impl Pep723Script { @@ -26,12 +27,35 @@ 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((metadata, data)) = extract_script_tag(&contents)? else { + return Ok(None); + }; + + // Parse the metadata. + let metadata = Pep723Metadata::from_string(metadata)?; + + Ok(Some(Self { path: file.as_ref().to_path_buf(), metadata, + data, })) } + + /// Replace the existing metadata in the file with new metadata and write the updated content. + pub async fn replace_metadata(&self, new_metadata: &str) -> Result<(), Pep723Error> { + let new_content = format!("{}{}", serialize_metadata(new_metadata), self.data); + + fs_err::tokio::write(&self.path, new_content) + .await + .map_err(std::convert::Into::into) + } } /// PEP 723 metadata as parsed from a `script` comment block. @@ -43,28 +67,16 @@ pub struct Pep723Metadata { pub dependencies: 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()), - }; - - // 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. + pub fn from_string(raw: String) -> Result { + let metadata = toml::from_str(&raw)?; + Ok(Pep723Metadata { raw, ..metadata }) } } @@ -94,34 +106,11 @@ 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)) -} - /// Given the contents of a Python file, extract the `script` metadata block, with leading comment -/// hashes removed. +/// hashes removed and the python script. /// /// See: -fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { +fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { // Identify the opening pragma. let Some(index) = FINDER.find(contents) else { return Ok(None); @@ -149,9 +138,14 @@ fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { // > 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 { + + 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; }; @@ -163,11 +157,13 @@ fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { // 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. // @@ -202,12 +198,36 @@ fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { // Join the lines into a single string. let toml = toml.join("\n") + "\n"; + let python_script = python_script.join("\n") + "\n"; + + Ok(Some((toml, python_script))) +} + +/// 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); + + 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; + #[test] fn missing_space() { let contents = indoc::indoc! {r" @@ -269,9 +289,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,11 +305,21 @@ mod tests { ] "}; + 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 = super::extract_script_tag(contents.as_bytes()) .unwrap() .unwrap(); - assert_eq!(actual, expected); + assert_eq!(actual.0, expected_metadata); + assert_eq!(actual.1, expected_data); } #[test] @@ -312,7 +348,8 @@ mod tests { let actual = super::extract_script_tag(contents.as_bytes()) .unwrap() - .unwrap(); + .unwrap() + .0; assert_eq!(actual, expected); } @@ -341,8 +378,42 @@ mod tests { let actual = super::extract_script_tag(contents.as_bytes()) .unwrap() - .unwrap(); + .unwrap() + .0; 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 b105c98c1adf..cb55e0619ec9 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, + dependency_target: DependencyTarget, } #[derive(Error, Debug)] @@ -47,11 +48,19 @@ 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 { + Script, + 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, dependency_target: DependencyTarget) -> Result { Ok(Self { - doc: pyproject.raw.parse().map_err(Box::new)?, + doc: raw.parse().map_err(Box::new)?, + dependency_target, }) } @@ -83,6 +92,32 @@ 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 toml_edit::Table, Error> { + let doc = match self.dependency_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.dependency_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 +128,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 +189,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 +219,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 +288,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 +342,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 +388,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 6dad72e6e8c7..8414a446a3a4 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 30d9a3eaf5c6..e689d6c1c40f 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -15,24 +15,34 @@ 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, PythonDownloads, PythonInstallation, + PythonPreference, PythonRequest, VersionRequest, +}; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; use uv_resolver::FlatIndex; +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; +/// Represents the destination where dependencies are added, either to a project or a script. +enum DependencyDestination { + Project(VirtualProject), + Script(Pep723Script), +} + /// Add one or more packages to the project requirements. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn add( @@ -50,6 +60,7 @@ pub(crate) async fn add( package: Option, python: Option, settings: ResolverInstallerSettings, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, preview: PreviewMode, @@ -63,43 +74,93 @@ 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"))?, + let download_reporter = PythonDownloadReporter::single(printer); + let (dependency_destination, venv) = if let Some(script) = script { + // (1) Explicit request from user + let python_request = if let Some(request) = python.as_deref() { + Some(PythonRequest::parse(request)) + // (2) Request from `.python-version` + } else if let Some(request) = request_from_version_file(&CWD).await? { + Some(request) + // (3) `Requires-Python` in `pyproject.toml` + } else { + script + .metadata + .requires_python + .clone() + .map(|requires_python| { + PythonRequest::Version(VersionRequest::Range(requires_python)) + }) + }; + + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); + + let interpreter = PythonInstallation::find_or_download( + python_request, + EnvironmentPreference::Any, + python_preference, + python_downloads, + &client_builder, + cache, + Some(&download_reporter), ) + .await? + .into_interpreter(); + + // Create a virtual environment. + let temp_dir = cache.environment()?; + let venv = uv_virtualenv::create_venv( + temp_dir.path(), + interpreter, + uv_virtualenv::Prompt::None, + false, + false, + false, + )?; + + (DependencyDestination::Script(script), venv) } else { - VirtualProject::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? + }; - // 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()) + // 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 => (), } - 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?; + // 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?; + (DependencyDestination::Project(project), venv) + }; let client_builder = BaseClientBuilder::new() .connectivity(connectivity) @@ -183,8 +244,15 @@ pub(crate) async fn add( .await?; // Add the requirements to the `pyproject.toml`. - let existing = project.pyproject_toml(); - let mut pyproject = PyProjectTomlMut::from_toml(existing)?; + let mut toml = match &dependency_destination { + DependencyDestination::Script(script) => { + PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script) + } + DependencyDestination::Project(project) => { + let raw = project.pyproject_toml().raw.clone(); + PyProjectTomlMut::from_toml(&raw, DependencyTarget::PyProjectToml) + } + }?; let mut edits = Vec::with_capacity(requirements.len()); for mut requirement in requirements { // Add the specified extras. @@ -192,48 +260,48 @@ 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, - 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()), - }; + let (requirement, source) = match dependency_destination { + DependencyDestination::Script(_) => (pep508_rs::Requirement::from(requirement), None), + DependencyDestination::Project(_) if raw_sources => { + (pep508_rs::Requirement::from(requirement), None) + } + DependencyDestination::Project(ref project) => { + // 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, + 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(); + // Ignore the PEP 508 source. + let mut requirement = pep508_rs::Requirement::from(requirement); + requirement.clear_url(); - (requirement, source) + (requirement, source) + } }; - // 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())? } }; @@ -247,21 +315,40 @@ 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(); + let modified = match &dependency_destination { + DependencyDestination::Script(script) => { + if content == script.metadata.raw { + debug!("No changes to dependencies; skipping update"); + false + } else { + script.replace_metadata(&content).await?; + true + } + } + DependencyDestination::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 `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock` // to exist at all. if frozen { return Ok(ExitStatus::Success); } + // If `--script`, exit early. There's no reason to lock and sync. + let DependencyDestination::Project(project) = dependency_destination else { + return Ok(ExitStatus::Success); + }; + + let existing = project.pyproject_toml(); // Update the `pypackage.toml` in-memory. let project = project .clone() @@ -357,13 +444,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 +461,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())?; } } diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index abe75f6865b0..c469346fd781 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 c0129ec6ec76..970eeb31f1e2 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; @@ -17,6 +18,12 @@ use crate::commands::{project, ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; +/// Represents the destination where dependencies are added, either to a project or a script. +enum DependencyDestination { + Project(VirtualProject), + Script(Pep723Script), +} + /// Remove one or more packages from the project requirements. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn remove( @@ -28,6 +35,7 @@ pub(crate) async fn remove( package: Option, python: Option, settings: ResolverInstallerSettings, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, preview: PreviewMode, @@ -41,41 +49,58 @@ 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 dependency_destination = if let Some(script) = script { + DependencyDestination::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? + }; + + DependencyDestination::Project(project) }; - let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; + let mut toml = match &dependency_destination { + DependencyDestination::Script(script) => { + PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script) + } + DependencyDestination::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 +109,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 &dependency_destination { + DependencyDestination::Script(script) => { + script.replace_metadata(&toml.to_string()).await?; + } + DependencyDestination::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 +126,11 @@ pub(crate) async fn remove( return Ok(ExitStatus::Success); } + // If `--script`, exit early. There's no reason to lock and sync. + let DependencyDestination::Project(project) = dependency_destination else { + return Ok(ExitStatus::Success); + }; + // Discover or create the virtual environment. let venv = project::get_or_init_environment( project.workspace(), @@ -139,7 +174,7 @@ pub(crate) async fn remove( let state = SharedState::default(); project::sync::do_sync( - &VirtualProject::Project(project), + &project, &venv, &lock.lock, &extras, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c5f093cf0744..d8d17e189ab6 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1141,6 +1141,12 @@ async fn run_project( .combine(Refresh::from(args.settings.reinstall.clone())) .combine(Refresh::from(args.settings.upgrade.clone())), ); + // If the target is a PEP 723 script, parse it. + let script = if let Some(script) = args.script { + Pep723Script::read(&script).await? + } else { + None + }; commands::add( args.locked, @@ -1157,6 +1163,7 @@ async fn run_project( args.package, args.python, args.settings, + script, globals.python_preference, globals.python_downloads, globals.preview, @@ -1179,6 +1186,12 @@ async fn run_project( .combine(Refresh::from(args.settings.reinstall.clone())) .combine(Refresh::from(args.settings.upgrade.clone())), ); + // If the target is a PEP 723 script, parse it. + let script = if let Some(script) = args.script { + Pep723Script::read(&script).await? + } else { + None + }; commands::remove( args.locked, @@ -1189,6 +1202,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 be54b1de8edd..8e2d3856cc8e 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -706,6 +706,7 @@ pub(crate) struct AddSettings { pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, + pub(crate) script: Option, } impl AddSettings { @@ -731,6 +732,7 @@ impl AddSettings { refresh, package, python, + script, } = args; let requirements = requirements @@ -765,6 +767,7 @@ impl AddSettings { resolver_installer_options(installer, build), filesystem, ), + script, } } } @@ -782,6 +785,7 @@ pub(crate) struct RemoveSettings { pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, + pub(crate) script: Option, } impl RemoveSettings { @@ -800,6 +804,7 @@ impl RemoveSettings { refresh, package, python, + script, } = args; let dependency_type = if let Some(group) = optional { @@ -823,6 +828,7 @@ impl RemoveSettings { resolver_installer_options(installer, build), filesystem, ), + script, } } } diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index a11ef0d9e88e..2cd0ec8d27ca 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2912,3 +2912,184 @@ fn add_repeat() -> Result<()> { Ok(()) } + +/// Add to a PEP732 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(()) +} + +/// 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(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 284031ba05cd..550631310d5f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -711,6 +711,8 @@ uv add [OPTIONS] ...
--rev rev

Commit to use when adding a dependency from Git

+
--script script

Specifies the Python script where the dependency will be added

+
--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 +969,8 @@ 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

    Specifies the Python script where the dependency will be removed

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

    From 9f19a6838d1315f2b6eaff77de5079e488dccfeb Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Sat, 10 Aug 2024 20:31:33 +0200 Subject: [PATCH 02/10] Review comments --- crates/uv-scripts/src/lib.rs | 40 +++++++++-- crates/uv/src/commands/project/add.rs | 95 +++++++++++++++++++++------ crates/uv/src/lib.rs | 24 +++---- crates/uv/tests/edit.rs | 82 +++++++++++++++++++++++ 4 files changed, 203 insertions(+), 38 deletions(-) diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 2359829fa877..8c9af026e1d2 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -19,7 +19,7 @@ static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")) pub struct Pep723Script { pub path: PathBuf, pub metadata: Pep723Metadata, - pub data: String, + pub raw: String, } impl Pep723Script { @@ -34,7 +34,7 @@ impl Pep723Script { }; // Extract the `script` tag. - let Some((metadata, data)) = extract_script_tag(&contents)? else { + let Some((metadata, raw)) = extract_script_tag(&contents)? else { return Ok(None); }; @@ -44,13 +44,13 @@ impl Pep723Script { Ok(Some(Self { path: file.as_ref().to_path_buf(), metadata, - data, + raw, })) } /// Replace the existing metadata in the file with new metadata and write the updated content. pub async fn replace_metadata(&self, new_metadata: &str) -> Result<(), Pep723Error> { - let new_content = format!("{}{}", serialize_metadata(new_metadata), self.data); + let new_content = format!("{}{}", serialize_metadata(new_metadata), self.raw); fs_err::tokio::write(&self.path, new_content) .await @@ -107,8 +107,38 @@ pub enum Pep723Error { } /// Given the contents of a Python file, extract the `script` metadata block, with leading comment -/// hashes removed and the python script. +/// hashes removed, and the remaining Python script code. +/// +/// The function returns a tuple where: +/// - The first element is the extracted metadata as a string, with comment hashes removed. +/// - The second element is the remaining Python code of the script. +/// +/// # Example +/// +/// Given the following input string representing the contents of a Python script: +/// +/// ```python +/// # /// script +/// # requires-python = '>=3.11' +/// # dependencies = [ +/// # 'requests<3', +/// # 'rich', +/// # ] +/// # /// +/// +/// import requests +/// +/// print("Hello, World!") +/// ``` +/// +/// This function would return: /// +/// ```rust +/// ( +/// "requires-python = '>=3.11'\ndependencies = [\n 'requests<3',\n 'rich',\n]", +/// "import requests\n\nprint(\"Hello, World!\")\n" +/// ) +/// ``` /// See: fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { // Identify the opening pragma. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index e689d6c1c40f..8438421e1078 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -76,6 +76,31 @@ pub(crate) async fn add( let download_reporter = PythonDownloadReporter::single(printer); let (dependency_destination, venv) = if let Some(script) = script { + // If we found a PEP 723 script and the user provided a project-only setting, warn. + if !extras.is_empty() { + warn_user_once!("Extras are not supported for Python scripts with inline metadata"); + } + 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" + ); + } + // (1) Explicit request from user let python_request = if let Some(request) = python.as_deref() { Some(PythonRequest::parse(request)) @@ -261,39 +286,30 @@ pub(crate) async fn add( requirement.extras.dedup(); let (requirement, source) = match dependency_destination { - DependencyDestination::Script(_) => (pep508_rs::Requirement::from(requirement), None), - DependencyDestination::Project(_) if raw_sources => { + DependencyDestination::Script(_) | DependencyDestination::Project(_) if raw_sources => { (pep508_rs::Requirement::from(requirement), None) } + DependencyDestination::Script(_) => resolve_and_process_requirement( + requirement, + false, + editable, + rev.clone(), + tag.clone(), + branch.clone(), + )?, DependencyDestination::Project(ref project) => { - // 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(), + resolve_and_process_requirement( + requirement, workspace, 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) + )? } }; // Update the `pyproject.toml`. @@ -513,6 +529,43 @@ 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_and_process_requirement( + requirement: pypi_types::Requirement, + workspace: bool, + editable: Option, + rev: Option, + tag: Option, + branch: Option, +) -> Result<(pep508_rs::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)) +} + #[derive(Debug, Clone)] struct DependencyEdit<'a> { dependency_type: &'a DependencyType, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index d8d17e189ab6..6cc59893c373 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -135,6 +135,18 @@ 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::Add(uv_cli::AddArgs { + script: Some(script), + .. + }) = &**command + { + Pep723Script::read(&script).await? + } else if let ProjectCommand::Remove(uv_cli::RemoveArgs { + script: Some(script), + .. + }) = &**command + { + Pep723Script::read(&script).await? } else { None } @@ -1141,12 +1153,6 @@ async fn run_project( .combine(Refresh::from(args.settings.reinstall.clone())) .combine(Refresh::from(args.settings.upgrade.clone())), ); - // If the target is a PEP 723 script, parse it. - let script = if let Some(script) = args.script { - Pep723Script::read(&script).await? - } else { - None - }; commands::add( args.locked, @@ -1186,12 +1192,6 @@ async fn run_project( .combine(Refresh::from(args.settings.reinstall.clone())) .combine(Refresh::from(args.settings.upgrade.clone())), ); - // If the target is a PEP 723 script, parse it. - let script = if let Some(script) = args.script { - Pep723Script::read(&script).await? - } else { - None - }; commands::remove( args.locked, diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 2cd0ec8d27ca..12ab9ae411af 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -3082,6 +3082,88 @@ fn remove_last_dep_script() -> Result<()> { # /// + 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 From 1f897821a96c4c614f9922988e8fedabb37d5c13 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Sat, 10 Aug 2024 20:35:03 +0200 Subject: [PATCH 03/10] Add warning for remove --- crates/uv/src/commands/project/remove.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 970eeb31f1e2..9ed08c09615c 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -50,6 +50,27 @@ pub(crate) async fn remove( } let dependency_destination = 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" + ); + } DependencyDestination::Script(script) } else { // Find the project in the workspace. From 1aa15eb8ff074ca036c0a7518c2a4a00ff927f64 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Sat, 10 Aug 2024 21:04:41 +0200 Subject: [PATCH 04/10] `from_str` for Pep732Metadata --- crates/uv-scripts/src/lib.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 8c9af026e1d2..e9d8ccabf70e 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::io; use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::sync::LazyLock; use memchr::memmem::Finder; @@ -39,7 +40,7 @@ impl Pep723Script { }; // Parse the metadata. - let metadata = Pep723Metadata::from_string(metadata)?; + let metadata = Pep723Metadata::from_str(&metadata)?; Ok(Some(Self { path: file.as_ref().to_path_buf(), @@ -72,11 +73,15 @@ pub struct Pep723Metadata { pub raw: String, } -impl Pep723Metadata { +impl FromStr for Pep723Metadata { + type Err = Pep723Error; /// Parse `Pep723Metadata` from a raw TOML string. - pub fn from_string(raw: String) -> Result { - let metadata = toml::from_str(&raw)?; - Ok(Pep723Metadata { raw, ..metadata }) + fn from_str(raw: &str) -> Result { + let metadata = toml::from_str(raw)?; + Ok(Pep723Metadata { + raw: raw.to_string(), + ..metadata + }) } } From 6505a9dad6da847a92db80537b0b8a75e3106689 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Sat, 10 Aug 2024 22:48:02 +0200 Subject: [PATCH 05/10] Support creating metadata file for scripts --- crates/uv-scripts/Cargo.toml | 2 - crates/uv-scripts/src/lib.rs | 92 +++++++++++++++++++++-- crates/uv/src/commands/project/add.rs | 42 +++++++++-- crates/uv/src/lib.rs | 8 +- crates/uv/tests/edit.rs | 102 ++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 23 deletions(-) diff --git a/crates/uv-scripts/Cargo.toml b/crates/uv-scripts/Cargo.toml index ff8def78adfe..ccd45aca4025 100644 --- a/crates/uv-scripts/Cargo.toml +++ b/crates/uv-scripts/Cargo.toml @@ -19,6 +19,4 @@ 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 e9d8ccabf70e..295e0285b49d 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -5,6 +5,7 @@ use std::str::FromStr; use std::sync::LazyLock; use memchr::memmem::Finder; +use pep440_rs::VersionSpecifiers; use serde::Deserialize; use thiserror::Error; @@ -20,7 +21,11 @@ static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")) pub struct Pep723Script { pub path: PathBuf, 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 { @@ -35,7 +40,7 @@ impl Pep723Script { }; // Extract the `script` tag. - let Some((metadata, raw)) = extract_script_tag(&contents)? else { + let Some((prelude, metadata, raw)) = extract_script_tag(&contents)? else { return Ok(None); }; @@ -46,12 +51,55 @@ impl Pep723Script { path: file.as_ref().to_path_buf(), metadata, raw, + 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 (raw, prelude) = extract_shebang(&contents)?; + + // Parse the metadata. + let metadata = Pep723Metadata::from_str(&default_metadata)?; + + Ok(Self { + path: file.as_ref().to_path_buf(), + metadata, + raw, + prelude: prelude.unwrap_or(String::new()), + }) + } + /// Replace the existing metadata in the file with new metadata and write the updated content. pub async fn replace_metadata(&self, new_metadata: &str) -> Result<(), Pep723Error> { - let new_content = format!("{}{}", serialize_metadata(new_metadata), self.raw); + let new_content = format!( + "{}{}{}", + if self.prelude.is_empty() { + String::new() + } else { + format!("{}\n", self.prelude) + }, + serialize_metadata(new_metadata), + self.raw + ); fs_err::tokio::write(&self.path, new_content) .await @@ -111,18 +159,20 @@ pub enum Pep723Error { Toml(#[from] toml::de::Error), } -/// Given the contents of a Python file, extract the `script` metadata block, with leading comment -/// hashes removed, and the remaining Python script code. +/// 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 code. /// /// The function returns a tuple where: -/// - The first element is the extracted metadata as a string, with comment hashes removed. -/// - The second element is the remaining Python code of the script. +/// - The first element is the preceding content, which may include a shebang or other lines before the `script` metadata block. +/// - The second element is the extracted metadata as a string with comment hashes removed. +/// - The third element is the remaining Python code of the script. /// /// # Example /// /// Given the following input string representing the contents of a Python script: /// /// ```python +/// #!/usr/bin/env python3 /// # /// script /// # requires-python = '>=3.11' /// # dependencies = [ @@ -140,12 +190,14 @@ pub enum Pep723Error { /// /// ```rust /// ( +/// "#!/usr/bin/env python3\n", /// "requires-python = '>=3.11'\ndependencies = [\n 'requests<3',\n 'rich',\n]", /// "import requests\n\nprint(\"Hello, World!\")\n" /// ) /// ``` +/// /// See: -fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { +fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { // Identify the opening pragma. let Some(index) = FINDER.find(contents) else { return Ok(None); @@ -157,6 +209,12 @@ fn extract_script_tag(contents: &[u8]) -> Result, Pep72 } // Decode as UTF-8. + let prelude = if index != 0 { + std::str::from_utf8(&contents[..index])? + } else { + "" + } + .to_string(); let contents = &contents[index..]; let contents = std::str::from_utf8(contents)?; @@ -235,7 +293,25 @@ fn extract_script_tag(contents: &[u8]) -> Result, Pep72 let toml = toml.join("\n") + "\n"; let python_script = python_script.join("\n") + "\n"; - Ok(Some((toml, python_script))) + Ok(Some((prelude, toml, python_script))) +} + +/// Extracts the shebang line from the given file contents and returns it along with the remaining content. +fn extract_shebang(contents: &[u8]) -> Result<(String, Option), Pep723Error> { + let contents = std::str::from_utf8(contents)?; + + let mut lines = contents.lines(); + + // 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((remaining_content, Some(shebang))); + } + } + + Ok((contents.to_string(), None)) } /// Formats the provided metadata by prefixing each line with `#` and wrapping it with script markers. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 8438421e1078..d81246818d39 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -1,4 +1,5 @@ use std::collections::hash_map::Entry; +use std::path::PathBuf; use anyhow::{Context, Result}; use owo_colors::OwoColorize; @@ -20,7 +21,7 @@ use uv_python::{ 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; @@ -60,7 +61,7 @@ pub(crate) async fn add( package: Option, python: Option, settings: ResolverInstallerSettings, - script: Option, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, preview: PreviewMode, @@ -100,7 +101,40 @@ pub(crate) async fn add( "`--no_sync` is a no-op for Python scripts with inline metadata, which always run in isolation" ); } + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); + + let script = if let Some(script) = Pep723Script::read(&script).await? { + script + } else { + // As no metadata was found in the script, we will create a default metadata table for the script. + // (1) Explicit request from user + let python_request = if let Some(request) = python.as_deref() { + PythonRequest::parse(request) + // (2) Request from `.python-version` + } else if let Some(request) = request_from_version_file(&CWD).await? { + request + } else { + PythonRequest::Any + }; + let reporter = PythonDownloadReporter::single(printer); + 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? + }; // (1) Explicit request from user let python_request = if let Some(request) = python.as_deref() { Some(PythonRequest::parse(request)) @@ -118,10 +152,6 @@ pub(crate) async fn add( }) }; - let client_builder = BaseClientBuilder::new() - .connectivity(connectivity) - .native_tls(native_tls); - let interpreter = PythonInstallation::find_or_download( python_request, EnvironmentPreference::Any, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 6cc59893c373..466b0943ea24 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -135,12 +135,6 @@ 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::Add(uv_cli::AddArgs { - script: Some(script), - .. - }) = &**command - { - Pep723Script::read(&script).await? } else if let ProjectCommand::Remove(uv_cli::RemoveArgs { script: Some(script), .. @@ -1169,7 +1163,7 @@ async fn run_project( args.package, args.python, args.settings, - script, + args.script, globals.python_preference, globals.python_downloads, globals.preview, diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 12ab9ae411af..0e9e3d1923d0 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2975,6 +2975,108 @@ fn add_script() -> Result<()> { Ok(()) } +/// Add to a script without 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 metadata table +#[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(()) +} + /// Remove from a PEP732 script #[test] fn remove_script() -> Result<()> { From e43595e1143b1cc5749ce08a9a713d0019416301 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Sun, 11 Aug 2024 00:12:36 +0200 Subject: [PATCH 06/10] Test for shebang in python script --- crates/uv-scripts/src/lib.rs | 57 +++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 295e0285b49d..8af02dc64e08 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -167,8 +167,6 @@ pub enum Pep723Error { /// - The second element is the extracted metadata as a string with comment hashes removed. /// - The third element is the remaining Python code of the script. /// -/// # Example -/// /// Given the following input string representing the contents of a Python script: /// /// ```python @@ -188,13 +186,11 @@ pub enum Pep723Error { /// /// This function would return: /// -/// ```rust /// ( /// "#!/usr/bin/env python3\n", /// "requires-python = '>=3.11'\ndependencies = [\n 'requests<3',\n 'rich',\n]", /// "import requests\n\nprint(\"Hello, World!\")\n" /// ) -/// ``` /// /// See: fn extract_script_tag(contents: &[u8]) -> Result, Pep723Error> { @@ -429,10 +425,55 @@ mod tests { .unwrap() .unwrap(); - assert_eq!(actual.0, expected_metadata); - assert_eq!(actual.1, expected_data); + assert_eq!(actual.0, String::new()); + assert_eq!(actual.1, expected_metadata); + assert_eq!(actual.2, 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 = super::extract_script_tag(contents.as_bytes()) + .unwrap() + .unwrap(); + + assert_eq!(actual.0, "#!/usr/bin/env python3\n".to_string()); + assert_eq!(actual.1, expected_metadata); + assert_eq!(actual.2, expected_data); + } #[test] fn embedded_comment() { let contents = indoc::indoc! {r" @@ -460,7 +501,7 @@ mod tests { let actual = super::extract_script_tag(contents.as_bytes()) .unwrap() .unwrap() - .0; + .1; assert_eq!(actual, expected); } @@ -490,7 +531,7 @@ mod tests { let actual = super::extract_script_tag(contents.as_bytes()) .unwrap() .unwrap() - .0; + .1; assert_eq!(actual, expected); } From f08322b931d436eb54943c4c2b2791854ee27f9d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 10 Aug 2024 20:39:18 -0400 Subject: [PATCH 07/10] Tweaks --- crates/uv-cli/src/lib.rs | 20 ++- crates/uv-scripts/src/lib.rs | 324 +++++++++++++++++------------------ crates/uv/src/settings.rs | 12 +- crates/uv/tests/edit.rs | 8 - docs/reference/cli.md | 8 +- 5 files changed, 189 insertions(+), 183 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 77360190fd2d..20985b582828 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)] + pub script: Option, + /// The Python interpreter to use for resolving and syncing. /// /// See `uv help python` for details on Python discovery and supported @@ -2462,10 +2472,6 @@ pub struct AddArgs { help_heading = "Python options" )] pub python: Option, - - /// Specifies the Python script where the dependency will be added. - #[arg(long)] - pub script: Option, } #[derive(Args)] @@ -2513,9 +2519,13 @@ pub struct RemoveArgs { #[arg(long, conflicts_with = "isolated")] pub package: Option, - /// Specifies the Python script where the dependency will be removed. + /// 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/src/lib.rs b/crates/uv-scripts/src/lib.rs index 8af02dc64e08..9cdd12c84f59 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -19,11 +19,12 @@ 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, } @@ -40,18 +41,18 @@ impl Pep723Script { }; // Extract the `script` tag. - let Some((prelude, metadata, raw)) = extract_script_tag(&contents)? else { + let Some(script_tag) = ScriptTag::parse(&contents)? else { return Ok(None); }; // Parse the metadata. - let metadata = Pep723Metadata::from_str(&metadata)?; + let metadata = Pep723Metadata::from_str(&script_tag.metadata)?; Ok(Some(Self { path: file.as_ref().to_path_buf(), metadata, - raw, - prelude, + raw: script_tag.script, + prelude: script_tag.prelude, })) } @@ -75,16 +76,16 @@ impl Pep723Script { requires_python = requires_python, }; - let (raw, prelude) = extract_shebang(&contents)?; + 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, - prelude: prelude.unwrap_or(String::new()), }) } @@ -114,7 +115,7 @@ 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)] @@ -123,6 +124,7 @@ pub struct Pep723Metadata { impl FromStr for Pep723Metadata { type Err = Pep723Error; + /// Parse `Pep723Metadata` from a raw TOML string. fn from_str(raw: &str) -> Result { let metadata = toml::from_str(raw)?; @@ -159,141 +161,152 @@ pub enum Pep723Error { Toml(#[from] toml::de::Error), } -/// 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 code. -/// -/// The function returns a tuple where: -/// - The first element is the preceding content, which may include a shebang or other lines before the `script` metadata block. -/// - The second element is the extracted metadata as a string with comment hashes removed. -/// - The third element is the remaining Python code of the 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: -/// -/// ( -/// "#!/usr/bin/env python3\n", -/// "requires-python = '>=3.11'\ndependencies = [\n 'requests<3',\n 'rich',\n]", -/// "import requests\n\nprint(\"Hello, World!\")\n" -/// ) -/// -/// See: -fn extract_script_tag(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); - } +#[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, +} - // Decode as UTF-8. - let prelude = if index != 0 { - std::str::from_utf8(&contents[..index])? - } else { - "" - } - .to_string(); - let contents = &contents[index..]; - let contents = std::str::from_utf8(contents)?; +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); + }; - let mut lines = contents.lines(); + // 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); + } - // Ensure that the first line is exactly `# /// script`. - if !lines.next().is_some_and(|line| line == "# /// script") { - return Ok(None); - } + // Decode as UTF-8. + let prelude = if index != 0 { + std::str::from_utf8(&contents[..index])? + } else { + "" + } + .to_string(); + let contents = &contents[index..]; + let contents = std::str::from_utf8(contents)?; - // > 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![]; - - 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; - }; + let mut lines = contents.lines(); - // If the line is empty, continue. - if line.is_empty() { - toml.push(""); - continue; + // Ensure that the first line is exactly `# /// script`. + if !lines.next().is_some_and(|line| line == "# /// script") { + return Ok(None); } - // Otherwise, the line _must_ start with ` `. - let Some(line) = line.strip_prefix(' ') else { - python_script.push(line); - python_script.extend(lines); - break; + // > 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![]; + + 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 metadata = toml.join("\n") + "\n"; + let script = python_script.join("\n") + "\n"; - toml.push(line); + Ok(Some(Self { + prelude, + metadata, + script, + })) } - // 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 toml = toml.join("\n") + "\n"; - let python_script = python_script.join("\n") + "\n"; - - Ok(Some((prelude, toml, python_script))) } -/// Extracts the shebang line from the given file contents and returns it along with the remaining content. -fn extract_shebang(contents: &[u8]) -> Result<(String, Option), Pep723Error> { +/// 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(); @@ -303,11 +316,11 @@ fn extract_shebang(contents: &[u8]) -> Result<(String, Option), Pep723Er if first_line.starts_with("#!") { let shebang = first_line.to_string(); let remaining_content: String = lines.collect::>().join("\n"); - return Ok((remaining_content, Some(shebang))); + return Ok((Some(shebang), remaining_content)); } } - Ok((contents.to_string(), None)) + Ok((None, contents.to_string())) } /// Formats the provided metadata by prefixing each line with `#` and wrapping it with script markers. @@ -333,7 +346,7 @@ fn serialize_metadata(metadata: &str) -> String { #[cfg(test)] mod tests { - use crate::serialize_metadata; + use crate::{serialize_metadata, ScriptTag}; #[test] fn missing_space() { @@ -343,10 +356,7 @@ mod tests { # /// "}; - assert_eq!( - super::extract_script_tag(contents.as_bytes()).unwrap(), - None - ); + assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None); } #[test] @@ -360,10 +370,7 @@ mod tests { # ] "}; - assert_eq!( - super::extract_script_tag(contents.as_bytes()).unwrap(), - None - ); + assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None); } #[test] @@ -380,10 +387,7 @@ mod tests { # "}; - assert_eq!( - super::extract_script_tag(contents.as_bytes()).unwrap(), - None - ); + assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None); } #[test] @@ -421,13 +425,11 @@ mod tests { data = resp.json() "}; - let actual = super::extract_script_tag(contents.as_bytes()) - .unwrap() - .unwrap(); + let actual = ScriptTag::parse(contents.as_bytes()).unwrap().unwrap(); - assert_eq!(actual.0, String::new()); - assert_eq!(actual.1, expected_metadata); - assert_eq!(actual.2, expected_data); + assert_eq!(actual.prelude, String::new()); + assert_eq!(actual.metadata, expected_metadata); + assert_eq!(actual.script, expected_data); } #[test] @@ -466,13 +468,11 @@ mod tests { data = resp.json() "}; - let actual = super::extract_script_tag(contents.as_bytes()) - .unwrap() - .unwrap(); + let actual = ScriptTag::parse(contents.as_bytes()).unwrap().unwrap(); - assert_eq!(actual.0, "#!/usr/bin/env python3\n".to_string()); - assert_eq!(actual.1, expected_metadata); - assert_eq!(actual.2, expected_data); + 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() { @@ -498,10 +498,10 @@ mod tests { ''' "}; - let actual = super::extract_script_tag(contents.as_bytes()) + let actual = ScriptTag::parse(contents.as_bytes()) .unwrap() .unwrap() - .1; + .metadata; assert_eq!(actual, expected); } @@ -528,10 +528,10 @@ mod tests { ] "}; - let actual = super::extract_script_tag(contents.as_bytes()) + let actual = ScriptTag::parse(contents.as_bytes()) .unwrap() .unwrap() - .1; + .metadata; assert_eq!(actual, expected); } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 8e2d3856cc8e..10da1ee3783f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -703,10 +703,10 @@ 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, - pub(crate) script: Option, } impl AddSettings { @@ -731,8 +731,8 @@ impl AddSettings { build, refresh, package, - python, script, + python, } = args; let requirements = requirements @@ -759,6 +759,7 @@ impl AddSettings { tag, branch, package, + script, python, editable: flag(editable, no_editable), extras: extra.unwrap_or_default(), @@ -767,7 +768,6 @@ impl AddSettings { resolver_installer_options(installer, build), filesystem, ), - script, } } } @@ -782,10 +782,10 @@ 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, - pub(crate) script: Option, } impl RemoveSettings { @@ -803,8 +803,8 @@ impl RemoveSettings { build, refresh, package, - python, script, + python, } = args; let dependency_type = if let Some(group) = optional { @@ -822,13 +822,13 @@ impl RemoveSettings { packages, dependency_type, package, + script, python, refresh: Refresh::from(refresh), settings: ResolverInstallerSettings::combine( resolver_installer_options(installer, build), filesystem, ), - script, } } } diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 0e9e3d1923d0..02bd97ddaa09 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2928,7 +2928,6 @@ fn add_script() -> Result<()> { # ] # /// - import requests from rich.pretty import pprint @@ -2962,7 +2961,6 @@ fn add_script() -> Result<()> { # ] # /// - import requests from rich.pretty import pprint @@ -3093,7 +3091,6 @@ fn remove_script() -> Result<()> { # ] # /// - import requests from rich.pretty import pprint @@ -3126,7 +3123,6 @@ fn remove_script() -> Result<()> { # ] # /// - import requests from rich.pretty import pprint @@ -3153,7 +3149,6 @@ fn remove_last_dep_script() -> Result<()> { # ] # /// - import requests from rich.pretty import pprint @@ -3183,7 +3178,6 @@ fn remove_last_dep_script() -> Result<()> { # dependencies = [] # /// - import requests from rich.pretty import pprint @@ -3211,7 +3205,6 @@ fn add_git_to_script() -> Result<()> { # ] # /// - import requests from rich.pretty import pprint @@ -3265,7 +3258,6 @@ fn add_git_to_script() -> Result<()> { # uv-public-pypackage = { git = "https://github.com/astral-test/uv-public-pypackage", tag = "0.0.1" } # /// - import requests from rich.pretty import pprint diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 550631310d5f..d7387221c3ec 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -711,7 +711,9 @@ uv add [OPTIONS] ...
    --rev rev

    Commit to use when adding a dependency from Git

    -
    --script script

    Specifies the Python script where the dependency will be added

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

    @@ -969,7 +971,9 @@ 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

    Specifies the Python script where the dependency will be removed

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

    From 7ef3dfeadb4085aed717d70619fc3d0e2bdb70b8 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 10 Aug 2024 21:07:11 -0400 Subject: [PATCH 08/10] Refactor --- crates/uv-scripts/src/lib.rs | 12 +-- crates/uv/src/commands/project/add.rs | 122 +++++++++++++---------- crates/uv/src/commands/project/remove.rs | 35 ++++--- 3 files changed, 92 insertions(+), 77 deletions(-) diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 9cdd12c84f59..80c9e8adf764 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -211,13 +211,10 @@ impl ScriptTag { return Ok(None); } + // Extract the preceding content. + let prelude = std::str::from_utf8(&contents[..index])?; + // Decode as UTF-8. - let prelude = if index != 0 { - std::str::from_utf8(&contents[..index])? - } else { - "" - } - .to_string(); let contents = &contents[index..]; let contents = std::str::from_utf8(contents)?; @@ -235,6 +232,7 @@ impl ScriptTag { // > 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() { @@ -260,6 +258,7 @@ impl ScriptTag { toml.push(line); } + // Find the closing `# ///`. The precedence is such that we need to identify the _last_ such // line. // @@ -293,6 +292,7 @@ impl ScriptTag { 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"; diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index d81246818d39..10e02dfe09d9 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -3,9 +3,10 @@ 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}; @@ -17,8 +18,8 @@ use uv_distribution::DistributionDatabase; use uv_fs::CWD; use uv_normalize::PackageName; use uv_python::{ - request_from_version_file, EnvironmentPreference, PythonDownloads, PythonInstallation, - PythonPreference, PythonRequest, VersionRequest, + request_from_version_file, EnvironmentPreference, Interpreter, PythonDownloads, + PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest, }; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; use uv_resolver::{FlatIndex, RequiresPython}; @@ -38,12 +39,6 @@ use crate::commands::{pip, project, ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; -/// Represents the destination where dependencies are added, either to a project or a script. -enum DependencyDestination { - Project(VirtualProject), - Script(Pep723Script), -} - /// Add one or more packages to the project requirements. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn add( @@ -75,8 +70,9 @@ pub(crate) async fn add( warn_user_once!("`uv add` is experimental and may change without warning"); } - let download_reporter = PythonDownloadReporter::single(printer); - let (dependency_destination, venv) = if let Some(script) = script { + let reporter = PythonDownloadReporter::single(printer); + + let target = if let Some(script) = script { // If we found a PEP 723 script and the user provided a project-only setting, warn. if !extras.is_empty() { warn_user_once!("Extras are not supported for Python scripts with inline metadata"); @@ -101,25 +97,27 @@ pub(crate) async fn add( "`--no_sync` is a no-op for Python scripts with inline metadata, which always run in isolation" ); } + 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 { - // As no metadata was found in the script, we will create a default metadata table for the script. - - // (1) Explicit request from user let python_request = if let Some(request) = python.as_deref() { + // (1) Explicit request from user PythonRequest::parse(request) - // (2) Request from `.python-version` } 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 reporter = PythonDownloadReporter::single(printer); + let interpreter = PythonInstallation::find_or_download( Some(python_request), EnvironmentPreference::Any, @@ -131,18 +129,20 @@ pub(crate) async fn add( ) .await? .into_interpreter(); + let requires_python = RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()); Pep723Script::create(&script, requires_python.specifiers()).await? }; - // (1) Explicit request from user + let python_request = if let Some(request) = python.as_deref() { + // (1) Explicit request from user Some(PythonRequest::parse(request)) - // (2) Request from `.python-version` } else if let Some(request) = request_from_version_file(&CWD).await? { + // (2) Request from `.python-version` Some(request) - // (3) `Requires-Python` in `pyproject.toml` } else { + // (3) `Requires-Python` in `pyproject.toml` script .metadata .requires_python @@ -159,23 +159,12 @@ pub(crate) async fn add( python_downloads, &client_builder, cache, - Some(&download_reporter), + Some(&reporter), ) .await? .into_interpreter(); - // Create a virtual environment. - let temp_dir = cache.environment()?; - let venv = uv_virtualenv::create_venv( - temp_dir.path(), - interpreter, - uv_virtualenv::Prompt::None, - false, - false, - false, - )?; - - (DependencyDestination::Script(script), venv) + Target::Script(script, Box::new(interpreter)) } else { // Find the project in the workspace. let project = if let Some(package) = package { @@ -214,7 +203,8 @@ pub(crate) async fn add( printer, ) .await?; - (DependencyDestination::Project(project), venv) + + Target::Project(project, venv) }; let client_builder = BaseClientBuilder::new() @@ -236,7 +226,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() { @@ -248,7 +238,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. @@ -269,7 +259,7 @@ pub(crate) async fn add( &client, cache, &build_constraints, - venv.interpreter(), + target.interpreter(), &settings.index_locations, &flat_index, &state.index, @@ -298,15 +288,15 @@ pub(crate) async fn add( .resolve() .await?; - // Add the requirements to the `pyproject.toml`. - let mut toml = match &dependency_destination { - DependencyDestination::Script(script) => { + // 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) } - DependencyDestination::Project(project) => { - let raw = project.pyproject_toml().raw.clone(); - PyProjectTomlMut::from_toml(&raw, DependencyTarget::PyProjectToml) - } + 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 { @@ -315,11 +305,11 @@ pub(crate) async fn add( requirement.extras.sort_unstable(); requirement.extras.dedup(); - let (requirement, source) = match dependency_destination { - DependencyDestination::Script(_) | DependencyDestination::Project(_) if raw_sources => { + let (requirement, source) = match target { + Target::Script(_, _) | Target::Project(_, _) if raw_sources => { (pep508_rs::Requirement::from(requirement), None) } - DependencyDestination::Script(_) => resolve_and_process_requirement( + Target::Script(_, _) => resolve_requirement( requirement, false, editable, @@ -327,12 +317,12 @@ pub(crate) async fn add( tag.clone(), branch.clone(), )?, - DependencyDestination::Project(ref project) => { + Target::Project(ref project, _) => { let workspace = project .workspace() .packages() .contains_key(&requirement.name); - resolve_and_process_requirement( + resolve_requirement( requirement, workspace, editable, @@ -342,6 +332,7 @@ pub(crate) async fn add( )? } }; + // Update the `pyproject.toml`. let edit = match dependency_type { DependencyType::Production => toml.add_dependency(&requirement, source.as_ref())?, @@ -360,10 +351,11 @@ pub(crate) async fn add( }); } - // Save the modified `pyproject.toml`. let content = toml.to_string(); - let modified = match &dependency_destination { - DependencyDestination::Script(script) => { + + // 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 @@ -372,7 +364,7 @@ pub(crate) async fn add( true } } - DependencyDestination::Project(project) => { + Target::Project(project, _) => { if content == *project.pyproject_toml().raw { debug!("No changes to dependencies; skipping update"); false @@ -390,11 +382,12 @@ pub(crate) async fn add( } // If `--script`, exit early. There's no reason to lock and sync. - let DependencyDestination::Project(project) = dependency_destination else { + let Target::Project(project, venv) = target else { return Ok(ExitStatus::Success); }; let existing = project.pyproject_toml(); + // Update the `pypackage.toml` in-memory. let project = project .clone() @@ -560,14 +553,14 @@ pub(crate) async fn add( } /// Resolves the source for a requirement and processes it into a PEP 508 compliant format. -fn resolve_and_process_requirement( +fn resolve_requirement( requirement: pypi_types::Requirement, workspace: bool, editable: Option, rev: Option, tag: Option, branch: Option, -) -> Result<(pep508_rs::Requirement, Option), anyhow::Error> { +) -> Result<(Requirement, Option), anyhow::Error> { let result = Source::from_requirement( &requirement.name, requirement.source.clone(), @@ -596,6 +589,25 @@ fn resolve_and_process_requirement( 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/remove.rs b/crates/uv/src/commands/project/remove.rs index 9ed08c09615c..5342fc2b0504 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -18,12 +18,6 @@ use crate::commands::{project, ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; -/// Represents the destination where dependencies are added, either to a project or a script. -enum DependencyDestination { - Project(VirtualProject), - Script(Pep723Script), -} - /// Remove one or more packages from the project requirements. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn remove( @@ -49,7 +43,7 @@ pub(crate) async fn remove( warn_user_once!("`uv remove` is experimental and may change without warning"); } - let dependency_destination = if let Some(script) = script { + 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!( @@ -71,7 +65,7 @@ pub(crate) async fn remove( "`--no_sync` is a no-op for Python scripts with inline metadata, which always run in isolation" ); } - DependencyDestination::Script(script) + Target::Script(script) } else { // Find the project in the workspace. let project = if let Some(package) = package { @@ -85,14 +79,14 @@ pub(crate) async fn remove( VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await? }; - DependencyDestination::Project(project) + Target::Project(project) }; - let mut toml = match &dependency_destination { - DependencyDestination::Script(script) => { + let mut toml = match &target { + Target::Script(script) => { PyProjectTomlMut::from_toml(&script.metadata.raw, DependencyTarget::Script) } - DependencyDestination::Project(project) => PyProjectTomlMut::from_toml( + Target::Project(project) => PyProjectTomlMut::from_toml( project.pyproject_toml().raw.as_ref(), DependencyTarget::PyProjectToml, ), @@ -131,11 +125,11 @@ pub(crate) async fn remove( } // Save the modified dependencies. - match &dependency_destination { - DependencyDestination::Script(script) => { + match &target { + Target::Script(script) => { script.replace_metadata(&toml.to_string()).await?; } - DependencyDestination::Project(project) => { + Target::Project(project) => { let pyproject_path = project.root().join("pyproject.toml"); fs_err::write(pyproject_path, toml.to_string())?; } @@ -148,7 +142,7 @@ pub(crate) async fn remove( } // If `--script`, exit early. There's no reason to lock and sync. - let DependencyDestination::Project(project) = dependency_destination else { + let Target::Project(project) = target else { return Ok(ExitStatus::Success); }; @@ -216,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 From 40cba4456fcf7c95d3ae4495c84ff68fd4a82842 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 10 Aug 2024 21:13:33 -0400 Subject: [PATCH 09/10] Minor tweaks --- crates/uv-cli/src/lib.rs | 2 +- crates/uv-workspace/src/pyproject_mut.rs | 14 +++--- crates/uv/src/commands/project/add.rs | 3 -- crates/uv/tests/edit.rs | 60 ++++++++++++++++++++++-- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 20985b582828..1790d981c847 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2457,7 +2457,7 @@ pub struct AddArgs { /// 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)] + #[arg(long, conflicts_with = "dev", conflicts_with = "optional")] pub script: Option, /// The Python interpreter to use for resolving and syncing. diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index bdb5e5c7d807..c2596f08a6a4 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -16,7 +16,7 @@ use crate::pyproject::{DependencyType, Source}; /// preserving comments and other structure, such as `uv add` and `uv remove`. pub struct PyProjectTomlMut { doc: DocumentMut, - dependency_target: DependencyTarget, + target: DependencyTarget, } #[derive(Error, Debug)] @@ -57,10 +57,10 @@ pub enum DependencyTarget { impl PyProjectTomlMut { /// Initialize a [`PyProjectTomlMut`] from a [`str`]. - pub fn from_toml(raw: &str, dependency_target: DependencyTarget) -> Result { + pub fn from_toml(raw: &str, target: DependencyTarget) -> Result { Ok(Self { doc: raw.parse().map_err(Box::new)?, - dependency_target, + target, }) } @@ -93,8 +93,8 @@ impl PyProjectTomlMut { } /// Retrieves a mutable reference to the root `Table` of the TOML document, creating the `project` table if necessary. - fn doc(&mut self) -> Result<&mut toml_edit::Table, Error> { - let doc = match self.dependency_target { + fn doc(&mut self) -> Result<&mut Table, Error> { + let doc = match self.target { DependencyTarget::Script => self.doc.as_table_mut(), DependencyTarget::PyProjectToml => self .doc @@ -107,8 +107,8 @@ impl PyProjectTomlMut { } /// 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.dependency_target { + fn doc_mut(&mut self) -> Result, Error> { + let doc = match self.target { DependencyTarget::Script => Some(self.doc.as_table_mut()), DependencyTarget::PyProjectToml => self .doc diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 10e02dfe09d9..bda947bc7ae4 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -74,9 +74,6 @@ pub(crate) async fn add( let target = if let Some(script) = script { // If we found a PEP 723 script and the user provided a project-only setting, warn. - if !extras.is_empty() { - warn_user_once!("Extras are not supported for Python scripts with inline metadata"); - } if package.is_some() { warn_user_once!( "`--package` is a no-op for Python scripts with inline metadata, which always run in isolation" diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 02bd97ddaa09..061dac21ee81 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2913,7 +2913,7 @@ fn add_repeat() -> Result<()> { Ok(()) } -/// Add to a PEP732 script +/// Add to a PEP 732 script. #[test] fn add_script() -> Result<()> { let context = TestContext::new("3.12"); @@ -2973,7 +2973,7 @@ fn add_script() -> Result<()> { Ok(()) } -/// Add to a script without metadata table +/// Add to a script without an existing metadata table. #[test] fn add_script_without_metadata_table() -> Result<()> { let context = TestContext::new("3.12"); @@ -3023,7 +3023,7 @@ fn add_script_without_metadata_table() -> Result<()> { Ok(()) } -/// Add to a script without metadata table +/// 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"); @@ -3075,7 +3075,59 @@ fn add_script_without_metadata_table_with_shebang() -> Result<()> { Ok(()) } -/// Remove from a PEP732 script +/// 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"); From 3096e0fd15da67859716f8b6b9253e5b2f12ea83 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 10 Aug 2024 21:18:09 -0400 Subject: [PATCH 10/10] Add some comments --- crates/uv-scripts/Cargo.toml | 2 +- crates/uv-scripts/src/lib.rs | 10 ++++------ crates/uv-workspace/src/pyproject_mut.rs | 8 ++++++-- crates/uv/src/commands/project/add.rs | 13 +++++++------ crates/uv/src/commands/project/remove.rs | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/crates/uv-scripts/Cargo.toml b/crates/uv-scripts/Cargo.toml index ccd45aca4025..cf2265d493ad 100644 --- a/crates/uv-scripts/Cargo.toml +++ b/crates/uv-scripts/Cargo.toml @@ -15,8 +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 } -indoc = { workspace = true } diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 80c9e8adf764..50c3a2121964 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -90,21 +90,19 @@ impl Pep723Script { } /// Replace the existing metadata in the file with new metadata and write the updated content. - pub async fn replace_metadata(&self, new_metadata: &str) -> Result<(), Pep723Error> { - let new_content = format!( + 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(new_metadata), + serialize_metadata(metadata), self.raw ); - fs_err::tokio::write(&self.path, new_content) - .await - .map_err(std::convert::Into::into) + Ok(fs_err::tokio::write(&self.path, content).await?) } } diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index c2596f08a6a4..3e083f55af14 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -51,7 +51,9 @@ pub enum ArrayEdit { /// 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, } @@ -92,7 +94,8 @@ impl PyProjectTomlMut { Ok(()) } - /// Retrieves a mutable reference to the root `Table` of the TOML document, creating the `project` table if necessary. + /// 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(), @@ -106,7 +109,8 @@ impl PyProjectTomlMut { Ok(doc) } - /// Retrieves an optional mutable reference to the `project` `Table`, returning `None` if it doesn't exist. + /// 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()), diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index bda947bc7ae4..aa43a5abf38e 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -357,7 +357,7 @@ pub(crate) async fn add( debug!("No changes to dependencies; skipping update"); false } else { - script.replace_metadata(&content).await?; + script.write(&content).await?; true } } @@ -372,17 +372,18 @@ pub(crate) async fn add( } } }; - // If `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock` - // to exist at all. - if frozen { - return Ok(ExitStatus::Success); - } // 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. + if frozen { + return Ok(ExitStatus::Success); + } + let existing = project.pyproject_toml(); // Update the `pypackage.toml` in-memory. diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 5342fc2b0504..a8641edba9b2 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -127,7 +127,7 @@ pub(crate) async fn remove( // Save the modified dependencies. match &target { Target::Script(script) => { - script.replace_metadata(&toml.to_string()).await?; + script.write(&toml.to_string()).await?; } Target::Project(project) => { let pyproject_path = project.root().join("pyproject.toml");