From 807c16cfdf1c8904c04ea4ec205677d888381168 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Thu, 25 Jul 2024 19:44:26 +0200 Subject: [PATCH 1/3] Editable installs for `uv tool` --- crates/uv-cli/src/lib.rs | 5 ++++- crates/uv/src/commands/tool/install.rs | 24 +++++++++++++++------- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 3 +++ crates/uv/tests/tool_install.rs | 28 ++++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index aff0af791c3e..6316c68f80ae 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2254,10 +2254,13 @@ pub struct ToolInstallArgs { /// The package to install commands from. pub package: String, + #[arg(short, long, conflicts_with("from"))] + pub editable: bool, + /// The package to install commands from. /// /// This option is provided for parity with `uv tool run`, but is redundant with `package`. - #[arg(long, hide = true)] + #[arg(long, hide = true, conflicts_with("editable"))] pub from: Option, /// Include the following extra requirements. diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 0e2c3ed9ad8e..4da7a70daa3b 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -40,6 +40,7 @@ use crate::settings::ResolverInstallerSettings; /// Install a tool. pub(crate) async fn install( package: String, + editable: bool, from: Option, with: &[RequirementsSource], python: Option, @@ -82,6 +83,9 @@ pub(crate) async fn install( // Initialize any shared state. let state = SharedState::default(); + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); // Resolve the `from` requirement. let from = if let Some(from) = from { @@ -121,8 +125,19 @@ pub(crate) async fn install( from_requirement } else { + let requirements = if editable { + RequirementsSpecification::from_source( + &RequirementsSource::Editable(package), + &client_builder, + ) + .await? + .requirements + } else { + vec![RequirementsSpecification::parse_package(&package)?] + }; + resolve_names( - vec![RequirementsSpecification::parse_package(&package)?], + requirements, &interpreter, &settings, &state, @@ -139,12 +154,7 @@ pub(crate) async fn install( }; // Read the `--with` requirements. - let spec = { - let client_builder = BaseClientBuilder::new() - .connectivity(connectivity) - .native_tls(native_tls); - RequirementsSpecification::from_simple_sources(with, &client_builder).await? - }; + let spec = RequirementsSpecification::from_simple_sources(with, &client_builder).await?; // Resolve the `--from` and `--with` requirements. let requirements = { diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f35ab03f949b..79b837379c2f 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -672,6 +672,7 @@ async fn run(cli: Cli) -> Result { commands::tool_install( args.package, + args.editable, args.from, &requirements, args.python, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index aa1ae5e72456..51b90f6fd841 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -299,6 +299,7 @@ pub(crate) struct ToolInstallSettings { pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, pub(crate) force: bool, + pub(crate) editable: bool, } impl ToolInstallSettings { @@ -307,6 +308,7 @@ impl ToolInstallSettings { pub(crate) fn resolve(args: ToolInstallArgs, filesystem: Option) -> Self { let ToolInstallArgs { package, + editable, from, with, with_requirements, @@ -327,6 +329,7 @@ impl ToolInstallSettings { .collect(), python, force, + editable, refresh: Refresh::from(refresh), settings: ResolverInstallerSettings::combine( resolver_installer_options(installer, build), diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index e429c3d5464b..f12de8d10ea0 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -319,6 +319,34 @@ fn tool_install_version() { "###); } +/// Test an editable install of a tool +#[test] +fn tool_install_editable() { + 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` + uv_snapshot!(context.filters(), context.tool_install() + .arg("-e") + .arg(context.workspace_root.join("scripts/packages/black_editable")) + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: false + exit_code: 1 + ----- stdout ----- + No executables are provided by `black` + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + black==0.1.0 (from file://[WORKSPACE]/scripts/packages/black_editable) + "###); +} + /// Test installing a tool with `uv tool install --from` #[test] fn tool_install_from() { From 7475bb6d0e722035c653d4618895b9f3fbb29a16 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Thu, 25 Jul 2024 22:53:03 +0200 Subject: [PATCH 2/3] Add dummy executable --- crates/uv/tests/tool_install.rs | 86 ++++++++++++++++++- .../packages/black_editable/black/__init__.py | 8 +- .../packages/black_editable/pyproject.toml | 3 + 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index f12de8d10ea0..6a7a7bcb1ab1 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -333,10 +333,9 @@ fn tool_install_editable() { .env("UV_TOOL_DIR", tool_dir.as_os_str()) .env("XDG_BIN_HOME", bin_dir.as_os_str()) .env("PATH", bin_dir.as_os_str()), @r###" - success: false - exit_code: 1 + success: true + exit_code: 0 ----- stdout ----- - No executables are provided by `black` ----- stderr ----- warning: `uv tool install` is experimental and may change without warning @@ -344,6 +343,87 @@ fn tool_install_editable() { Prepared 1 package in [TIME] Installed 1 package in [TIME] + black==0.1.0 (from file://[WORKSPACE]/scripts/packages/black_editable) + Installed 1 executable: black + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("black") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); + assert!(executable.exists()); + + // On Windows, we can't snapshot an executable file. + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from black import main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black @ file://[WORKSPACE]/scripts/packages/black_editable"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + ] + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello world! + + ----- stderr ----- + "###); + + let context = TestContext::new("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd "###); } diff --git a/scripts/packages/black_editable/black/__init__.py b/scripts/packages/black_editable/black/__init__.py index b2dde251b1b4..4076f6c86e8d 100644 --- a/scripts/packages/black_editable/black/__init__.py +++ b/scripts/packages/black_editable/black/__init__.py @@ -1,2 +1,6 @@ -def a(): - pass +def main(): + print("Hello world!") + + +if __name__ == "__main__": + main() diff --git a/scripts/packages/black_editable/pyproject.toml b/scripts/packages/black_editable/pyproject.toml index 4f8bd991b624..96002aeb62f9 100644 --- a/scripts/packages/black_editable/pyproject.toml +++ b/scripts/packages/black_editable/pyproject.toml @@ -9,6 +9,9 @@ dependencies = [] requires-python = ">=3.11,<3.13" license = {text = "MIT"} +[project.scripts] +black = "black:main" + [project.optional-dependencies] colorama = ["colorama>=0.4.3"] uvloop = ["uvloop>=0.15.2"] From ce3980fdff7998a79e7e1d7cab53fde299a5d3b0 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 26 Jul 2024 15:51:10 -0400 Subject: [PATCH 3/3] Add more tests --- crates/uv-cli/src/lib.rs | 4 +- crates/uv/src/commands/tool/install.rs | 25 +++-- crates/uv/tests/tool_install.rs | 148 ++++++++++++++++++++++--- 3 files changed, 153 insertions(+), 24 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c5345ed5d677..27b680952041 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2283,13 +2283,13 @@ pub struct ToolInstallArgs { /// The package to install commands from. pub package: String, - #[arg(short, long, conflicts_with("from"))] + #[arg(short, long)] pub editable: bool, /// The package to install commands from. /// /// This option is provided for parity with `uv tool run`, but is redundant with `package`. - #[arg(long, hide = true, conflicts_with("editable"))] + #[arg(long, hide = true)] pub from: Option, /// Include the following extra requirements. diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 4da7a70daa3b..b5088707878d 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -95,9 +95,18 @@ pub(crate) async fn install( bail!("Package requirement (`{from}`) provided with `--from` conflicts with install request (`{package}`)", from = from.cyan(), package = package.cyan()) }; + let source = if editable { + RequirementsSource::Editable(from) + } else { + RequirementsSource::Package(from) + }; + let requirements = RequirementsSpecification::from_source(&source, &client_builder) + .await? + .requirements; + let from_requirement = { resolve_names( - vec![RequirementsSpecification::parse_package(&from)?], + requirements, &interpreter, &settings, &state, @@ -125,16 +134,14 @@ pub(crate) async fn install( from_requirement } else { - let requirements = if editable { - RequirementsSpecification::from_source( - &RequirementsSource::Editable(package), - &client_builder, - ) - .await? - .requirements + let source = if editable { + RequirementsSource::Editable(package.clone()) } else { - vec![RequirementsSpecification::parse_package(&package)?] + RequirementsSource::Package(package.clone()) }; + let requirements = RequirementsSpecification::from_source(&source, &client_builder) + .await? + .requirements; resolve_names( requirements, diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 6a7a7bcb1ab1..7f8e160a4fec 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -319,14 +319,14 @@ fn tool_install_version() { "###); } -/// Test an editable install of a tool +/// Test an editable installation of a tool. #[test] fn tool_install_editable() { 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` + // Install `black` as an editable package. uv_snapshot!(context.filters(), context.tool_install() .arg("-e") .arg(context.workspace_root.join("scripts/packages/black_editable")) @@ -361,7 +361,7 @@ fn tool_install_editable() { filters => context.filters(), }, { // Should run black in the virtual environment - assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + assert_snapshot!(fs_err::read_to_string(&executable).unwrap(), @r###" #![TEMP_DIR]/tools/black/bin/python # -*- coding: utf-8 -*- import re @@ -396,15 +396,41 @@ fn tool_install_editable() { ----- stderr ----- "###); - let context = TestContext::new("3.12") - .with_filtered_counts() - .with_filtered_exe_suffix(); - let tool_dir = context.temp_dir.child("tools"); - let bin_dir = context.temp_dir.child("bin"); + // Request `black`. It should retain the current installation. + // TODO(charlie): This is arguably incorrect, especially because the tool receipt removes the + // file path. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- - // Install `black` + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Installed 1 executable: black + "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + ] + "###); + }); + + // Request `black` at a different version. It should install a new version. uv_snapshot!(context.filters(), context.tool_install() .arg("black") + .arg("--from") + .arg("black==24.2.0") .env("UV_TOOL_DIR", tool_dir.as_os_str()) .env("XDG_BIN_HOME", bin_dir.as_os_str()) .env("PATH", bin_dir.as_os_str()), @r###" @@ -414,10 +440,12 @@ fn tool_install_editable() { ----- stderr ----- warning: `uv tool install` is experimental and may change without warning - Resolved [N] packages in [TIME] - Prepared [N] packages in [TIME] - Installed [N] packages in [TIME] - + black==24.3.0 + Resolved 6 packages in [TIME] + Prepared 6 packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 6 packages in [TIME] + - black==0.1.0 (from file://[WORKSPACE]/scripts/packages/black_editable) + + black==24.2.0 + click==8.1.7 + mypy-extensions==1.0.0 + packaging==24.0 @@ -425,6 +453,100 @@ fn tool_install_editable() { + platformdirs==4.2.0 Installed 2 executables: black, blackd "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black==24.2.0"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "###); + }); +} + +/// Test an editable installation of a tool using `--from`. +#[test] +fn tool_install_editable_from() { + 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` as an editable package. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("-e") + .arg("--from") + .arg(context.workspace_root.join("scripts/packages/black_editable")) + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + black==0.1.0 (from file://[WORKSPACE]/scripts/packages/black_editable) + Installed 1 executable: black + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("black") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); + assert!(executable.exists()); + + // On Windows, we can't snapshot an executable file. + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(&executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from black import main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black @ file://[WORKSPACE]/scripts/packages/black_editable"] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + ] + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello world! + + ----- stderr ----- + "###); } /// Test installing a tool with `uv tool install --from`