diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index 1e023e67e..b641a06a7 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -6,13 +6,13 @@ use toml_edit::Array; use toml_edit::Table; use toml_edit::Value; -use pypi_types::Requirement; +use pypi_types::{Requirement, VerbatimParsedUrl}; use uv_fs::PortablePath; /// A tool entry. #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "kebab-case")] +#[serde(try_from = "ToolWire", into = "ToolWire")] pub struct Tool { /// The requirements requested by the user during installation. requirements: Vec, @@ -22,6 +22,56 @@ pub struct Tool { entrypoints: Vec, } +#[derive(Clone, Debug, Deserialize)] +pub struct ToolWire { + pub requirements: Vec, + pub python: Option, + pub entrypoints: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum RequirementWire { + /// A [`Requirement`] following our uv-specific schema. + Requirement(Requirement), + /// A PEP 508-compatible requirement. We no longer write these, but there might be receipts out + /// there that still use them. + Deprecated(pep508_rs::Requirement), +} + +impl From for ToolWire { + fn from(tool: Tool) -> Self { + Self { + requirements: tool + .requirements + .into_iter() + .map(RequirementWire::Requirement) + .collect(), + python: tool.python, + entrypoints: tool.entrypoints, + } + } +} + +impl TryFrom for Tool { + type Error = serde::de::value::Error; + + fn try_from(tool: ToolWire) -> Result { + Ok(Self { + requirements: tool + .requirements + .into_iter() + .map(|req| match req { + RequirementWire::Requirement(requirements) => requirements, + RequirementWire::Deprecated(requirement) => Requirement::from(requirement), + }) + .collect(), + python: tool.python, + entrypoints: tool.entrypoints, + }) + } +} + #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct ToolEntrypoint { diff --git a/crates/uv/tests/tool_list.rs b/crates/uv/tests/tool_list.rs index df5ffdc2b..a00a2a0fb 100644 --- a/crates/uv/tests/tool_list.rs +++ b/crates/uv/tests/tool_list.rs @@ -3,9 +3,9 @@ use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::fixture::PathChild; -use fs_err as fs; - use common::{uv_snapshot, TestContext}; +use fs_err as fs; +use insta::assert_snapshot; mod common; @@ -170,3 +170,89 @@ fn tool_list_bad_environment() -> Result<()> { Ok(()) } + +#[test] +fn tool_list_deprecated() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + context + .tool_install() + .arg("black==24.2.0") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .assert() + .success(); + + // Ensure that we have a modern tool receipt. + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [{ name = "black", specifier = "==24.2.0" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "###); + }); + + // Replace with a legacy receipt. + fs::write( + tool_dir.join("black").join("uv-receipt.toml"), + r#" + [tool] + requirements = ["black==24.2.0"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "#, + )?; + + // Ensure that we can still list the tool. + uv_snapshot!(context.filters(), context.tool_list() + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 + - black + - blackd + + ----- stderr ----- + warning: `uv tool list` is experimental and may change without warning + "###); + + // Replace with an invalid receipt. + fs::write( + tool_dir.join("black").join("uv-receipt.toml"), + r#" + [tool] + requirements = ["black<>24.2.0"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "#, + )?; + + // Ensure that listing fails. + uv_snapshot!(context.filters(), context.tool_list() + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool list` is experimental and may change without warning + warning: Ignoring malformed tool `black` (run `uv tool uninstall black` to remove) + "###); + + Ok(()) +}