mirror of https://github.com/astral-sh/uv
Editable installs for `uv tool` (#5454)
## Summary Resolves #5436. ## Test Plan `cargo test` ```console ❯ ./target/debug/uv tool install -e ~/black warning: `uv tool install` is experimental and may change without warning Resolved 6 packages in 894ms Built black @ file:///Users/ahmedilyas/black Prepared 1 package in 468ms Installed 6 packages in 6ms + black==24.4.3.dev23+g7e2afc9 (from file:///Users/ahmedilyas/black) + click==8.1.7 + mypy-extensions==1.0.0 + packaging==24.1 + pathspec==0.12.1 + platformdirs==4.2.2 Installed 2 executables: black, blackd ``` venv has the `.pth` files. ```console ❯ eza /Users/ahmedilyas/Library/Application\ Support/uv/tools/black/lib/python3.12/site-packages/ _black.pth _virtualenv.py click mypy_extensions-1.0.0.dist-info packaging pathspec platformdirs _virtualenv.pth black-24.4.3.dev23+g7e2afc9.dist-info click-8.1.7.dist-info mypy_extensions.py packaging-24.1.dist-info pathspec-0.12.1.dist-info platformdirs-4.2.2.dist-info ``` --------- Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
parent
2186e967f6
commit
e8d7c0cb58
|
|
@ -2283,6 +2283,9 @@ pub struct ToolInstallArgs {
|
||||||
/// The package to install commands from.
|
/// The package to install commands from.
|
||||||
pub package: String,
|
pub package: String,
|
||||||
|
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub editable: bool,
|
||||||
|
|
||||||
/// The package to install commands from.
|
/// The package to install commands from.
|
||||||
///
|
///
|
||||||
/// This option is provided for parity with `uv tool run`, but is redundant with `package`.
|
/// This option is provided for parity with `uv tool run`, but is redundant with `package`.
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ use crate::settings::ResolverInstallerSettings;
|
||||||
/// Install a tool.
|
/// Install a tool.
|
||||||
pub(crate) async fn install(
|
pub(crate) async fn install(
|
||||||
package: String,
|
package: String,
|
||||||
|
editable: bool,
|
||||||
from: Option<String>,
|
from: Option<String>,
|
||||||
with: &[RequirementsSource],
|
with: &[RequirementsSource],
|
||||||
python: Option<String>,
|
python: Option<String>,
|
||||||
|
|
@ -82,6 +83,9 @@ pub(crate) async fn install(
|
||||||
|
|
||||||
// Initialize any shared state.
|
// Initialize any shared state.
|
||||||
let state = SharedState::default();
|
let state = SharedState::default();
|
||||||
|
let client_builder = BaseClientBuilder::new()
|
||||||
|
.connectivity(connectivity)
|
||||||
|
.native_tls(native_tls);
|
||||||
|
|
||||||
// Resolve the `from` requirement.
|
// Resolve the `from` requirement.
|
||||||
let from = if let Some(from) = from {
|
let from = if let Some(from) = from {
|
||||||
|
|
@ -91,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())
|
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 = {
|
let from_requirement = {
|
||||||
resolve_names(
|
resolve_names(
|
||||||
vec![RequirementsSpecification::parse_package(&from)?],
|
requirements,
|
||||||
&interpreter,
|
&interpreter,
|
||||||
&settings,
|
&settings,
|
||||||
&state,
|
&state,
|
||||||
|
|
@ -121,8 +134,17 @@ pub(crate) async fn install(
|
||||||
|
|
||||||
from_requirement
|
from_requirement
|
||||||
} else {
|
} else {
|
||||||
|
let source = if editable {
|
||||||
|
RequirementsSource::Editable(package.clone())
|
||||||
|
} else {
|
||||||
|
RequirementsSource::Package(package.clone())
|
||||||
|
};
|
||||||
|
let requirements = RequirementsSpecification::from_source(&source, &client_builder)
|
||||||
|
.await?
|
||||||
|
.requirements;
|
||||||
|
|
||||||
resolve_names(
|
resolve_names(
|
||||||
vec![RequirementsSpecification::parse_package(&package)?],
|
requirements,
|
||||||
&interpreter,
|
&interpreter,
|
||||||
&settings,
|
&settings,
|
||||||
&state,
|
&state,
|
||||||
|
|
@ -139,12 +161,7 @@ pub(crate) async fn install(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read the `--with` requirements.
|
// Read the `--with` requirements.
|
||||||
let spec = {
|
let spec = RequirementsSpecification::from_simple_sources(with, &client_builder).await?;
|
||||||
let client_builder = BaseClientBuilder::new()
|
|
||||||
.connectivity(connectivity)
|
|
||||||
.native_tls(native_tls);
|
|
||||||
RequirementsSpecification::from_simple_sources(with, &client_builder).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve the `--from` and `--with` requirements.
|
// Resolve the `--from` and `--with` requirements.
|
||||||
let requirements = {
|
let requirements = {
|
||||||
|
|
|
||||||
|
|
@ -681,6 +681,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
|
||||||
|
|
||||||
commands::tool_install(
|
commands::tool_install(
|
||||||
args.package,
|
args.package,
|
||||||
|
args.editable,
|
||||||
args.from,
|
args.from,
|
||||||
&requirements,
|
&requirements,
|
||||||
args.python,
|
args.python,
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,7 @@ pub(crate) struct ToolInstallSettings {
|
||||||
pub(crate) refresh: Refresh,
|
pub(crate) refresh: Refresh,
|
||||||
pub(crate) settings: ResolverInstallerSettings,
|
pub(crate) settings: ResolverInstallerSettings,
|
||||||
pub(crate) force: bool,
|
pub(crate) force: bool,
|
||||||
|
pub(crate) editable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolInstallSettings {
|
impl ToolInstallSettings {
|
||||||
|
|
@ -310,6 +311,7 @@ impl ToolInstallSettings {
|
||||||
pub(crate) fn resolve(args: ToolInstallArgs, filesystem: Option<FilesystemOptions>) -> Self {
|
pub(crate) fn resolve(args: ToolInstallArgs, filesystem: Option<FilesystemOptions>) -> Self {
|
||||||
let ToolInstallArgs {
|
let ToolInstallArgs {
|
||||||
package,
|
package,
|
||||||
|
editable,
|
||||||
from,
|
from,
|
||||||
with,
|
with,
|
||||||
with_requirements,
|
with_requirements,
|
||||||
|
|
@ -330,6 +332,7 @@ impl ToolInstallSettings {
|
||||||
.collect(),
|
.collect(),
|
||||||
python,
|
python,
|
||||||
force,
|
force,
|
||||||
|
editable,
|
||||||
refresh: Refresh::from(refresh),
|
refresh: Refresh::from(refresh),
|
||||||
settings: ResolverInstallerSettings::combine(
|
settings: ResolverInstallerSettings::combine(
|
||||||
resolver_installer_options(installer, build),
|
resolver_installer_options(installer, build),
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,236 @@ fn tool_install_version() {
|
||||||
"###);
|
"###);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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` as an editable package.
|
||||||
|
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: 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 -----
|
||||||
|
"###);
|
||||||
|
|
||||||
|
// 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 -----
|
||||||
|
|
||||||
|
----- 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###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
warning: `uv tool install` is experimental and may change without warning
|
||||||
|
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
|
||||||
|
+ pathspec==0.12.1
|
||||||
|
+ 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`
|
/// Test installing a tool with `uv tool install --from`
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_install_from() {
|
fn tool_install_from() {
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,6 @@
|
||||||
def a():
|
def main():
|
||||||
pass
|
print("Hello world!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ dependencies = []
|
||||||
requires-python = ">=3.11,<3.13"
|
requires-python = ">=3.11,<3.13"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
black = "black:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
colorama = ["colorama>=0.4.3"]
|
colorama = ["colorama>=0.4.3"]
|
||||||
uvloop = ["uvloop>=0.15.2"]
|
uvloop = ["uvloop>=0.15.2"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue