uv/crates/puffin-cli/tests/pip_uninstall.rs
Andrew Gallant 6c98ae9d77
pep440: rewrite the parser and make version comparisons cheaper (#789)
This PR builds on #780 by making both version parsing faster, and
perhaps more importantly, making version comparisons much faster.
Overall, these changes result in a considerable improvement for the
`boto3.in` workload. Here's the status quo:

```
$ time puffin pip-compile --no-build --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/requirements/boto3.in
Resolved 31 packages in 34.56s

real    34.579
user    34.004
sys     0.413
maxmem  2867 MB
faults  0
```

And now with this PR:

```
$ time puffin pip-compile --no-build --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/requirements/boto3.in
Resolved 31 packages in 9.20s

real    9.218
user    8.919
sys     0.165
maxmem  463 MB
faults  0
```

This particular workload gets stuck in pubgrub doing resolution, and
thus benefits mightily from a faster `Version::cmp` routine. With that
said, this change does also help a fair bit with "normal" runs:

```
$ hyperfine -w10 \
    "puffin-base pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in" \
    "puffin-cmparc pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in"
Benchmark 1: puffin-base pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in
  Time (mean ± σ):     337.5 ms ±   3.9 ms    [User: 310.5 ms, System: 73.2 ms]
  Range (min … max):   333.6 ms … 343.4 ms    10 runs

Benchmark 2: puffin-cmparc pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in
  Time (mean ± σ):     189.8 ms ±   3.0 ms    [User: 168.1 ms, System: 78.4 ms]
  Range (min … max):   185.0 ms … 196.2 ms    15 runs

Summary
  puffin-cmparc pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in ran
    1.78 ± 0.03 times faster than puffin-base pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in
```

There is perhaps some future work here (detailed in the commit
messages), but I suspect it would be more fruitful to explore ways of
making resolution itself and/or deserialization faster.

Fixes #373, Closes #396
2024-01-05 11:57:32 -05:00

528 lines
14 KiB
Rust

use std::iter;
use std::process::Command;
use anyhow::Result;
use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use insta_cmd::{assert_cmd_snapshot, get_cargo_bin};
use common::{BIN_NAME, INSTA_FILTERS};
use crate::common::create_venv_py312;
mod common;
#[test]
fn no_arguments() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: the following required arguments were not provided:
<PACKAGE|--requirement <REQUIREMENT>|--editable <EDITABLE>>
Usage: puffin pip-uninstall <PACKAGE|--requirement <REQUIREMENT>|--editable <EDITABLE>>
For more information, try '--help'.
"###);
Ok(())
}
#[test]
fn invalid_requirement() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("flask==1.0.x")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to parse `flask==1.0.x`
Caused by: after parsing 1.0, found ".x" after it, which is not part of a valid version
flask==1.0.x
^^^^^^^
"###);
Ok(())
}
#[test]
fn missing_requirements_txt() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("-r")
.arg("requirements.txt")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: failed to open file `requirements.txt`
Caused by: No such file or directory (os error 2)
"###);
Ok(())
}
#[test]
fn invalid_requirements_txt_requirement() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let requirements_txt = temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("flask==1.0.x")?;
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("-r")
.arg("requirements.txt")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Couldn't parse requirement in requirements.txt position 0 to 12
Caused by: after parsing 1.0, found ".x" after it, which is not part of a valid version
flask==1.0.x
^^^^^^^
"###);
Ok(())
}
#[test]
fn missing_pyproject_toml() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("-r")
.arg("pyproject.toml")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: failed to open file `pyproject.toml`
Caused by: No such file or directory (os error 2)
"###);
Ok(())
}
#[test]
fn invalid_pyproject_toml_syntax() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let pyproject_toml = temp_dir.child("pyproject.toml");
pyproject_toml.touch()?;
pyproject_toml.write_str("123 - 456")?;
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("-r")
.arg("pyproject.toml")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to read `pyproject.toml`
Caused by: TOML parse error at line 1, column 5
|
1 | 123 - 456
| ^
expected `.`, `=`
"###);
Ok(())
}
#[test]
fn invalid_pyproject_toml_schema() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let pyproject_toml = temp_dir.child("pyproject.toml");
pyproject_toml.touch()?;
pyproject_toml.write_str("[project]")?;
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("-r")
.arg("pyproject.toml")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to read `pyproject.toml`
Caused by: TOML parse error at line 1, column 1
|
1 | [project]
| ^^^^^^^^^
missing field `name`
"###);
Ok(())
}
#[test]
fn invalid_pyproject_toml_requirement() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let pyproject_toml = temp_dir.child("pyproject.toml");
pyproject_toml.touch()?;
pyproject_toml.write_str(
r#"[project]
name = "project"
dependencies = ["flask==1.0.x"]
"#,
)?;
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("-r")
.arg("pyproject.toml")
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to read `pyproject.toml`
Caused by: TOML parse error at line 3, column 16
|
3 | dependencies = ["flask==1.0.x"]
| ^^^^^^^^^^^^^^^^
after parsing 1.0, found ".x" after it, which is not part of a valid version
flask==1.0.x
^^^^^^^
"###);
Ok(())
}
#[test]
fn uninstall() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?;
let venv = create_venv_py312(&temp_dir, &cache_dir);
let requirements_txt = temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("MarkupSafe==2.1.3")?;
Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-sync")
.arg("requirements.txt")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.current_dir(&temp_dir)
.assert()
.success();
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import markupsafe")
.current_dir(&temp_dir)
.assert()
.success();
insta::with_settings!({
filters => INSTA_FILTERS.to_vec()
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("MarkupSafe")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.current_dir(&temp_dir), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Uninstalled 1 package in [TIME]
- markupsafe==2.1.3
"###);
});
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import markupsafe")
.current_dir(&temp_dir)
.assert()
.failure();
Ok(())
}
#[test]
fn missing_record() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?;
let venv = create_venv_py312(&temp_dir, &cache_dir);
let requirements_txt = temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("MarkupSafe==2.1.3")?;
Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-sync")
.arg("requirements.txt")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.current_dir(&temp_dir)
.assert()
.success();
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import markupsafe")
.current_dir(&temp_dir)
.assert()
.success();
// Delete the RECORD file.
let dist_info = venv
.join("lib")
.join("python3.12")
.join("site-packages")
.join("MarkupSafe-2.1.3.dist-info");
std::fs::remove_file(dist_info.join("RECORD"))?;
let filters: Vec<_> = iter::once((
"RECORD file not found at: .*/.venv",
"RECORD file not found at: [VENV_PATH]",
))
.chain(INSTA_FILTERS.to_vec())
.collect();
insta::with_settings!({
filters => filters,
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("MarkupSafe")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Cannot uninstall package; RECORD file not found at: [VENV_PATH]/lib/python3.12/site-packages/MarkupSafe-2.1.3.dist-info/RECORD
"###);
});
Ok(())
}
#[test]
fn uninstall_editable_by_name() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?;
let venv = create_venv_py312(&temp_dir, &cache_dir);
let current_dir = std::env::current_dir()?;
let workspace_dir = current_dir.join("..").join("..").canonicalize()?;
let filters: Vec<_> = iter::once((workspace_dir.to_str().unwrap(), "[WORKSPACE_DIR]"))
.chain(INSTA_FILTERS.to_vec())
.collect();
let requirements_txt = temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("-e ../../scripts/editable-installs/poetry_editable")?;
Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-sync")
.arg(requirements_txt.path())
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.assert()
.success();
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import poetry_editable")
.assert()
.success();
// Uninstall the editable by name.
insta::with_settings!({
filters => filters.clone()
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("poetry-editable")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
, @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Uninstalled 1 package in [TIME]
- poetry-editable==0.1.0 (from file://[WORKSPACE_DIR]/scripts/editable-installs/poetry_editable/)
"###);
});
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import poetry_editable")
.assert()
.failure();
Ok(())
}
#[test]
fn uninstall_editable_by_path() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?;
let venv = create_venv_py312(&temp_dir, &cache_dir);
let current_dir = std::env::current_dir()?;
let workspace_dir = current_dir.join("..").join("..").canonicalize()?;
let filters: Vec<_> = iter::once((workspace_dir.to_str().unwrap(), "[WORKSPACE_DIR]"))
.chain(INSTA_FILTERS.to_vec())
.collect();
let requirements_txt = temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("-e ../../scripts/editable-installs/poetry_editable")?;
Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-sync")
.arg(requirements_txt.path())
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.assert()
.success();
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import poetry_editable")
.assert()
.success();
// Uninstall the editable by path.
insta::with_settings!({
filters => filters.clone()
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("-e")
.arg("../../scripts/editable-installs/poetry_editable")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Uninstalled 1 package in [TIME]
- poetry-editable==0.1.0 (from file://[WORKSPACE_DIR]/scripts/editable-installs/poetry_editable/)
"###);
});
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import poetry_editable")
.assert()
.failure();
Ok(())
}
#[test]
fn uninstall_duplicate_editable() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?;
let venv = create_venv_py312(&temp_dir, &cache_dir);
let current_dir = std::env::current_dir()?;
let workspace_dir = current_dir.join("..").join("..").canonicalize()?;
let filters: Vec<_> = iter::once((workspace_dir.to_str().unwrap(), "[WORKSPACE_DIR]"))
.chain(INSTA_FILTERS.to_vec())
.collect();
let requirements_txt = temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("-e ../../scripts/editable-installs/poetry_editable")?;
Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-sync")
.arg(requirements_txt.path())
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.assert()
.success();
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import poetry_editable")
.assert()
.success();
// Uninstall the editable by both path and name.
insta::with_settings!({
filters => filters.clone()
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-uninstall")
.arg("poetry-editable")
.arg("-e")
.arg("../../scripts/editable-installs/poetry_editable")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Uninstalled 1 package in [TIME]
- poetry-editable==0.1.0 (from file://[WORKSPACE_DIR]/scripts/editable-installs/poetry_editable/)
"###);
});
Command::new(venv.join("bin").join("python"))
.arg("-c")
.arg("import poetry_editable")
.assert()
.failure();
Ok(())
}