Support legacy tool receipts with PEP 508 requirements (#5679)

## Summary

In #5494, I made breaking changes to the tool receipt format. This would
break existing tools for all users. This PR makes the change
backwards-compatible by supporting deserialization for the deprecated
format.

Closes https://github.com/astral-sh/uv/issues/5680.

## Test Plan

Beyond the automated tests, you can run `cargo run tool list` on your
existing machine.

Before:

```
warning: `uv tool list` is experimental and may change without warning
warning: Ignoring malformed tool `black` (run `uv tool uninstall black` to remove)
warning: Ignoring malformed tool `poetry` (run `uv tool uninstall poetry` to remove)
warning: Ignoring malformed tool `ruff` (run `uv tool uninstall ruff` to remove)
```

After:

```
warning: `uv tool list` is experimental and may change without warning
black v0.1.0
- black
poetry v1.8.3
- poetry
ruff v0.0.60
- ruff
```
This commit is contained in:
Charlie Marsh 2024-08-01 12:43:29 -04:00 committed by GitHub
parent b9b41d4a38
commit ff2e1fcec0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 140 additions and 4 deletions

View file

@ -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<Requirement>,
@ -22,6 +22,56 @@ pub struct Tool {
entrypoints: Vec<ToolEntrypoint>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ToolWire {
pub requirements: Vec<RequirementWire>,
pub python: Option<String>,
pub entrypoints: Vec<ToolEntrypoint>,
}
#[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<VerbatimParsedUrl>),
}
impl From<Tool> 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<ToolWire> for Tool {
type Error = serde::de::value::Error;
fn try_from(tool: ToolWire) -> Result<Self, Self::Error> {
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 {

View file

@ -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(())
}