mirror of https://github.com/astral-sh/uv
Support installing additional executables in `uv tool install` (#14014)
Close #6314 ## Summary Continuing from #7592. Created a new PR to rebase the old branch with `main`, cleaned up test errors, and improved readability. ## Test Plan Same test cases as in #7592. --------- Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
parent
e176e17144
commit
3df972f18a
|
|
@ -4490,6 +4490,10 @@ pub struct ToolInstallArgs {
|
|||
#[arg(long)]
|
||||
pub with_editable: Vec<comma::CommaSeparatedRequirements>,
|
||||
|
||||
/// Install executables from the following packages.
|
||||
#[arg(long)]
|
||||
pub with_executables_from: Vec<comma::CommaSeparatedRequirements>,
|
||||
|
||||
/// Constrain versions using the given requirements files.
|
||||
///
|
||||
/// Constraints files are `requirements.txt`-like files that only control the _version_ of a
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ impl TryFrom<ToolWire> for Tool {
|
|||
pub struct ToolEntrypoint {
|
||||
pub name: String,
|
||||
pub install_path: PathBuf,
|
||||
pub from: Option<String>,
|
||||
}
|
||||
|
||||
impl Display for ToolEntrypoint {
|
||||
|
|
@ -166,10 +167,10 @@ impl Tool {
|
|||
overrides: Vec<Requirement>,
|
||||
build_constraints: Vec<Requirement>,
|
||||
python: Option<PythonRequest>,
|
||||
entrypoints: impl Iterator<Item = ToolEntrypoint>,
|
||||
entrypoints: impl IntoIterator<Item = ToolEntrypoint>,
|
||||
options: ToolOptions,
|
||||
) -> Self {
|
||||
let mut entrypoints: Vec<_> = entrypoints.collect();
|
||||
let mut entrypoints: Vec<_> = entrypoints.into_iter().collect();
|
||||
entrypoints.sort();
|
||||
Self {
|
||||
requirements,
|
||||
|
|
@ -345,8 +346,15 @@ impl Tool {
|
|||
|
||||
impl ToolEntrypoint {
|
||||
/// Create a new [`ToolEntrypoint`].
|
||||
pub fn new(name: String, install_path: PathBuf) -> Self {
|
||||
Self { name, install_path }
|
||||
pub fn new(name: &str, install_path: PathBuf, from: String) -> Self {
|
||||
let name = name
|
||||
.trim_end_matches(std::env::consts::EXE_SUFFIX)
|
||||
.to_string();
|
||||
Self {
|
||||
name,
|
||||
install_path,
|
||||
from: Some(from),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the TOML table for this entrypoint.
|
||||
|
|
@ -358,6 +366,9 @@ impl ToolEntrypoint {
|
|||
// Use cross-platform slashes so the toml string type does not change
|
||||
value(PortablePath::from(&self.install_path).to_string()),
|
||||
);
|
||||
if let Some(from) = &self.from {
|
||||
table.insert("from", value(from));
|
||||
}
|
||||
table
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
use anyhow::{Context, bail};
|
||||
use itertools::Itertools;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::collections::Bound;
|
||||
use std::fmt::Write;
|
||||
use std::{collections::BTreeSet, ffi::OsString};
|
||||
use std::{
|
||||
collections::{BTreeSet, Bound},
|
||||
ffi::OsString,
|
||||
fmt::Write,
|
||||
path::Path,
|
||||
};
|
||||
use tracing::{debug, warn};
|
||||
use uv_cache::Cache;
|
||||
use uv_client::BaseClientBuilder;
|
||||
|
|
@ -22,12 +25,12 @@ use uv_python::{
|
|||
};
|
||||
use uv_settings::{PythonInstallMirrors, ToolOptions};
|
||||
use uv_shell::Shell;
|
||||
use uv_tool::{InstalledTools, Tool, ToolEntrypoint, entrypoint_paths, tool_executable_dir};
|
||||
use uv_warnings::warn_user;
|
||||
use uv_tool::{InstalledTools, Tool, ToolEntrypoint, entrypoint_paths};
|
||||
use uv_warnings::warn_user_once;
|
||||
|
||||
use crate::commands::pip;
|
||||
use crate::commands::project::ProjectError;
|
||||
use crate::commands::reporters::PythonDownloadReporter;
|
||||
use crate::commands::{ExitStatus, pip};
|
||||
use crate::printer::Printer;
|
||||
|
||||
/// Return all packages which contain an executable with the given name.
|
||||
|
|
@ -169,8 +172,9 @@ pub(crate) async fn refine_interpreter(
|
|||
pub(crate) fn finalize_tool_install(
|
||||
environment: &PythonEnvironment,
|
||||
name: &PackageName,
|
||||
entrypoints: &[PackageName],
|
||||
installed_tools: &InstalledTools,
|
||||
options: ToolOptions,
|
||||
options: &ToolOptions,
|
||||
force: bool,
|
||||
python: Option<PythonRequest>,
|
||||
requirements: Vec<Requirement>,
|
||||
|
|
@ -178,120 +182,152 @@ pub(crate) fn finalize_tool_install(
|
|||
overrides: Vec<Requirement>,
|
||||
build_constraints: Vec<Requirement>,
|
||||
printer: Printer,
|
||||
) -> anyhow::Result<ExitStatus> {
|
||||
let site_packages = SitePackages::from_environment(environment)?;
|
||||
let installed = site_packages.get_packages(name);
|
||||
let Some(installed_dist) = installed.first().copied() else {
|
||||
bail!("Expected at least one requirement")
|
||||
};
|
||||
|
||||
// Find a suitable path to install into
|
||||
let executable_directory = tool_executable_dir()?;
|
||||
) -> anyhow::Result<()> {
|
||||
let executable_directory = uv_tool::tool_executable_dir()?;
|
||||
fs_err::create_dir_all(&executable_directory)
|
||||
.context("Failed to create executable directory")?;
|
||||
|
||||
debug!(
|
||||
"Installing tool executables into: {}",
|
||||
executable_directory.user_display()
|
||||
);
|
||||
|
||||
let entry_points = entrypoint_paths(
|
||||
&site_packages,
|
||||
installed_dist.name(),
|
||||
installed_dist.version(),
|
||||
)?;
|
||||
|
||||
// Determine the entry points targets. Use a sorted collection for deterministic output.
|
||||
let target_entry_points = entry_points
|
||||
let mut installed_entrypoints = Vec::new();
|
||||
let site_packages = SitePackages::from_environment(environment)?;
|
||||
let ordered_packages = entrypoints
|
||||
// Install dependencies first
|
||||
.iter()
|
||||
.filter(|pkg| *pkg != name)
|
||||
.collect::<BTreeSet<_>>()
|
||||
// Then install the root package last
|
||||
.into_iter()
|
||||
.map(|(name, source_path)| {
|
||||
let target_path = executable_directory.join(
|
||||
source_path
|
||||
.file_name()
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
.unwrap_or_else(|| OsString::from(name.clone())),
|
||||
);
|
||||
(name, source_path, target_path)
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
.chain(std::iter::once(name));
|
||||
|
||||
if target_entry_points.is_empty() {
|
||||
writeln!(
|
||||
printer.stdout(),
|
||||
"No executables are provided by package `{from}`; removing tool",
|
||||
from = name.cyan()
|
||||
)?;
|
||||
for package in ordered_packages {
|
||||
if package == name {
|
||||
debug!("Installing entrypoints for tool `{package}`");
|
||||
} else {
|
||||
debug!("Installing entrypoints for `{package}` as part of tool `{name}`");
|
||||
}
|
||||
|
||||
hint_executable_from_dependency(name, &site_packages, printer)?;
|
||||
let installed = site_packages.get_packages(package);
|
||||
let dist = installed
|
||||
.first()
|
||||
.context("Expected at least one requirement")?;
|
||||
let dist_entrypoints = entrypoint_paths(&site_packages, dist.name(), dist.version())?;
|
||||
|
||||
// Clean up the environment we just created.
|
||||
installed_tools.remove_environment(name)?;
|
||||
// Determine the entry points targets. Use a sorted collection for deterministic output.
|
||||
let target_entrypoints = dist_entrypoints
|
||||
.into_iter()
|
||||
.map(|(name, source_path)| {
|
||||
let target_path = executable_directory.join(
|
||||
source_path
|
||||
.file_name()
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
.unwrap_or_else(|| OsString::from(name.clone())),
|
||||
);
|
||||
(name, source_path, target_path)
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
return Ok(ExitStatus::Failure);
|
||||
}
|
||||
if target_entrypoints.is_empty() {
|
||||
// If package is not the root package, suggest to install it as a dependency.
|
||||
if package != name {
|
||||
writeln!(
|
||||
printer.stdout(),
|
||||
"No executables are provided by package `{}`\n{}{} Use `--with {}` to include `{}` as a dependency without installing its executables.",
|
||||
package.cyan(),
|
||||
"hint".bold().cyan(),
|
||||
":".bold(),
|
||||
package.cyan(),
|
||||
package.cyan(),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Error if we're overwriting an existing entrypoint, unless the user passed `--force`.
|
||||
if !force {
|
||||
let mut existing_entry_points = target_entry_points
|
||||
.iter()
|
||||
.filter(|(_, _, target_path)| target_path.exists())
|
||||
.peekable();
|
||||
if existing_entry_points.peek().is_some() {
|
||||
// Clean up the environment we just created
|
||||
// For the root package, this is a fatal error
|
||||
writeln!(
|
||||
printer.stdout(),
|
||||
"No executables are provided by package `{}`; removing tool",
|
||||
package.cyan()
|
||||
)?;
|
||||
|
||||
hint_executable_from_dependency(package, &site_packages, printer)?;
|
||||
|
||||
// Clean up the environment we just created.
|
||||
installed_tools.remove_environment(name)?;
|
||||
|
||||
let existing_entry_points = existing_entry_points
|
||||
// SAFETY: We know the target has a filename because we just constructed it above
|
||||
.map(|(_, _, target)| target.file_name().unwrap().to_string_lossy())
|
||||
.collect::<Vec<_>>();
|
||||
let (s, exists) = if existing_entry_points.len() == 1 {
|
||||
("", "exists")
|
||||
} else {
|
||||
("s", "exist")
|
||||
};
|
||||
bail!(
|
||||
"Executable{s} already {exists}: {} (use `--force` to overwrite)",
|
||||
existing_entry_points
|
||||
.iter()
|
||||
.map(|name| name.bold())
|
||||
.join(", ")
|
||||
)
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to install entrypoints for `{}`",
|
||||
package.cyan()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
let itself = std::env::current_exe().ok();
|
||||
// Error if we're overwriting an existing entrypoint, unless the user passed `--force`.
|
||||
if !force {
|
||||
let mut existing_entrypoints = target_entrypoints
|
||||
.iter()
|
||||
.filter(|(_, _, target_path)| target_path.exists())
|
||||
.peekable();
|
||||
if existing_entrypoints.peek().is_some() {
|
||||
// Clean up the environment we just created
|
||||
installed_tools.remove_environment(name)?;
|
||||
|
||||
for (name, source_path, target_path) in &target_entry_points {
|
||||
debug!("Installing executable: `{name}`");
|
||||
|
||||
#[cfg(unix)]
|
||||
replace_symlink(source_path, target_path).context("Failed to install executable")?;
|
||||
let existing_entrypoints = existing_entrypoints
|
||||
// SAFETY: We know the target has a filename because we just constructed it above
|
||||
.map(|(_, _, target)| target.file_name().unwrap().to_string_lossy())
|
||||
.collect::<Vec<_>>();
|
||||
let (s, exists) = if existing_entrypoints.len() == 1 {
|
||||
("", "exists")
|
||||
} else {
|
||||
("s", "exist")
|
||||
};
|
||||
bail!(
|
||||
"Executable{s} already {exists}: {} (use `--force` to overwrite)",
|
||||
existing_entrypoints
|
||||
.iter()
|
||||
.map(|name| name.bold())
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
if itself.as_ref().is_some_and(|itself| {
|
||||
std::path::absolute(target_path).is_ok_and(|target| *itself == target)
|
||||
}) {
|
||||
self_replace::self_replace(source_path).context("Failed to install entrypoint")?;
|
||||
} else {
|
||||
fs_err::copy(source_path, target_path).context("Failed to install entrypoint")?;
|
||||
}
|
||||
}
|
||||
let itself = std::env::current_exe().ok();
|
||||
|
||||
let s = if target_entry_points.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
};
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"Installed {} executable{s}: {}",
|
||||
target_entry_points.len(),
|
||||
target_entry_points
|
||||
.iter()
|
||||
.map(|(name, _, _)| name.bold())
|
||||
.join(", ")
|
||||
)?;
|
||||
let mut names = BTreeSet::new();
|
||||
for (name, src, target) in target_entrypoints {
|
||||
debug!("Installing executable: `{name}`");
|
||||
|
||||
#[cfg(unix)]
|
||||
replace_symlink(src, &target).context("Failed to install executable")?;
|
||||
|
||||
#[cfg(windows)]
|
||||
if itself.as_ref().is_some_and(|itself| {
|
||||
std::path::absolute(&target).is_ok_and(|target| *itself == target)
|
||||
}) {
|
||||
self_replace::self_replace(src).context("Failed to install entrypoint")?;
|
||||
} else {
|
||||
fs_err::copy(src, &target).context("Failed to install entrypoint")?;
|
||||
}
|
||||
|
||||
let tool_entry = ToolEntrypoint::new(&name, target, package.to_string());
|
||||
names.insert(tool_entry.name.clone());
|
||||
installed_entrypoints.push(tool_entry);
|
||||
}
|
||||
|
||||
let s = if names.len() == 1 { "" } else { "s" };
|
||||
let from_pkg = if name == package {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" from `{package}`")
|
||||
};
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"Installed {} executable{s}{from_pkg}: {}",
|
||||
names.len(),
|
||||
names.iter().map(|name| name.bold()).join(", ")
|
||||
)?;
|
||||
}
|
||||
|
||||
debug!("Adding receipt for tool `{name}`");
|
||||
let tool = Tool::new(
|
||||
|
|
@ -300,45 +336,48 @@ pub(crate) fn finalize_tool_install(
|
|||
overrides,
|
||||
build_constraints,
|
||||
python,
|
||||
target_entry_points
|
||||
.into_iter()
|
||||
.map(|(name, _, target_path)| ToolEntrypoint::new(name, target_path)),
|
||||
options,
|
||||
installed_entrypoints,
|
||||
options.clone(),
|
||||
);
|
||||
installed_tools.add_tool_receipt(name, tool)?;
|
||||
|
||||
warn_out_of_path(&executable_directory);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn warn_out_of_path(executable_directory: &Path) {
|
||||
// If the executable directory isn't on the user's PATH, warn.
|
||||
if !Shell::contains_path(&executable_directory) {
|
||||
if !Shell::contains_path(executable_directory) {
|
||||
if let Some(shell) = Shell::from_env() {
|
||||
if let Some(command) = shell.prepend_path(&executable_directory) {
|
||||
if let Some(command) = shell.prepend_path(executable_directory) {
|
||||
if shell.supports_update() {
|
||||
warn_user!(
|
||||
warn_user_once!(
|
||||
"`{}` is not on your PATH. To use installed tools, run `{}` or `{}`.",
|
||||
executable_directory.simplified_display().cyan(),
|
||||
command.green(),
|
||||
"uv tool update-shell".green()
|
||||
);
|
||||
} else {
|
||||
warn_user!(
|
||||
warn_user_once!(
|
||||
"`{}` is not on your PATH. To use installed tools, run `{}`.",
|
||||
executable_directory.simplified_display().cyan(),
|
||||
command.green()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn_user!(
|
||||
warn_user_once!(
|
||||
"`{}` is not on your PATH. To use installed tools, add the directory to your PATH.",
|
||||
executable_directory.simplified_display().cyan(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn_user!(
|
||||
warn_user_once!(
|
||||
"`{}` is not on your PATH. To use installed tools, add the directory to your PATH.",
|
||||
executable_directory.simplified_display().cyan(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(ExitStatus::Success)
|
||||
}
|
||||
|
||||
/// Displays a hint if an executable matching the package name can be found in a dependency of the package.
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ pub(crate) async fn install(
|
|||
constraints: &[RequirementsSource],
|
||||
overrides: &[RequirementsSource],
|
||||
build_constraints: &[RequirementsSource],
|
||||
entrypoints: &[PackageName],
|
||||
python: Option<String>,
|
||||
install_mirrors: PythonInstallMirrors,
|
||||
force: bool,
|
||||
|
|
@ -113,7 +114,7 @@ pub(crate) async fn install(
|
|||
};
|
||||
|
||||
// Resolve the `--from` requirement.
|
||||
let from = match &request {
|
||||
let requirement = match &request {
|
||||
// Ex) `ruff`
|
||||
ToolRequest::Package {
|
||||
executable,
|
||||
|
|
@ -219,14 +220,16 @@ pub(crate) async fn install(
|
|||
}
|
||||
// Ex) `python`
|
||||
ToolRequest::Python { .. } => {
|
||||
return Err(anyhow::anyhow!(
|
||||
bail!(
|
||||
"Cannot install Python with `{}`. Did you mean to use `{}`?",
|
||||
"uv tool install".cyan(),
|
||||
"uv python install".cyan(),
|
||||
));
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let package_name = &requirement.name;
|
||||
|
||||
// If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable.
|
||||
let settings = if request.is_latest() {
|
||||
ResolverInstallerSettings {
|
||||
|
|
@ -234,7 +237,7 @@ pub(crate) async fn install(
|
|||
upgrade: settings
|
||||
.resolver
|
||||
.upgrade
|
||||
.combine(Upgrade::package(from.name.clone())),
|
||||
.combine(Upgrade::package(package_name.clone())),
|
||||
..settings.resolver
|
||||
},
|
||||
..settings
|
||||
|
|
@ -248,7 +251,7 @@ pub(crate) async fn install(
|
|||
ResolverInstallerSettings {
|
||||
reinstall: settings
|
||||
.reinstall
|
||||
.combine(Reinstall::package(from.name.clone())),
|
||||
.combine(Reinstall::package(package_name.clone())),
|
||||
..settings
|
||||
}
|
||||
} else {
|
||||
|
|
@ -268,7 +271,7 @@ pub(crate) async fn install(
|
|||
// Resolve the `--from` and `--with` requirements.
|
||||
let requirements = {
|
||||
let mut requirements = Vec::with_capacity(1 + with.len());
|
||||
requirements.push(from.clone());
|
||||
requirements.push(requirement.clone());
|
||||
requirements.extend(
|
||||
resolve_names(
|
||||
spec.requirements.clone(),
|
||||
|
|
@ -332,16 +335,16 @@ pub(crate) async fn install(
|
|||
// (If we find existing entrypoints later on, and the tool _doesn't_ exist, we'll avoid removing
|
||||
// the external tool's entrypoints (without `--force`).)
|
||||
let (existing_tool_receipt, invalid_tool_receipt) =
|
||||
match installed_tools.get_tool_receipt(&from.name) {
|
||||
match installed_tools.get_tool_receipt(package_name) {
|
||||
Ok(None) => (None, false),
|
||||
Ok(Some(receipt)) => (Some(receipt), false),
|
||||
Err(_) => {
|
||||
// If the tool is not installed properly, remove the environment and continue.
|
||||
match installed_tools.remove_environment(&from.name) {
|
||||
match installed_tools.remove_environment(package_name) {
|
||||
Ok(()) => {
|
||||
warn_user!(
|
||||
"Removed existing `{from}` with invalid receipt",
|
||||
from = from.name.cyan()
|
||||
"Removed existing `{}` with invalid receipt",
|
||||
package_name.cyan()
|
||||
);
|
||||
}
|
||||
Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
|
|
@ -355,20 +358,20 @@ pub(crate) async fn install(
|
|||
|
||||
let existing_environment =
|
||||
installed_tools
|
||||
.get_environment(&from.name, &cache)?
|
||||
.get_environment(package_name, &cache)?
|
||||
.filter(|environment| {
|
||||
if environment.uses(&interpreter) {
|
||||
trace!(
|
||||
"Existing interpreter matches the requested interpreter for `{}`: {}",
|
||||
from.name,
|
||||
package_name,
|
||||
environment.interpreter().sys_executable().display()
|
||||
);
|
||||
true
|
||||
} else {
|
||||
let _ = writeln!(
|
||||
printer.stderr(),
|
||||
"Ignoring existing environment for `{from}`: the requested Python interpreter does not match the environment interpreter",
|
||||
from = from.name.cyan(),
|
||||
"Ignoring existing environment for `{}`: the requested Python interpreter does not match the environment interpreter",
|
||||
package_name.cyan(),
|
||||
);
|
||||
false
|
||||
}
|
||||
|
|
@ -393,15 +396,17 @@ pub(crate) async fn install(
|
|||
{
|
||||
if *tool_receipt.options() != options {
|
||||
// ...but the options differ, we need to update the receipt.
|
||||
installed_tools
|
||||
.add_tool_receipt(&from.name, tool_receipt.clone().with_options(options))?;
|
||||
installed_tools.add_tool_receipt(
|
||||
package_name,
|
||||
tool_receipt.clone().with_options(options),
|
||||
)?;
|
||||
}
|
||||
|
||||
// We're done, though we might need to update the receipt.
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
"`{from}` is already installed",
|
||||
from = from.cyan()
|
||||
"`{}` is already installed",
|
||||
requirement.cyan()
|
||||
)?;
|
||||
|
||||
return Ok(ExitStatus::Success);
|
||||
|
|
@ -560,7 +565,7 @@ pub(crate) async fn install(
|
|||
},
|
||||
};
|
||||
|
||||
let environment = installed_tools.create_environment(&from.name, interpreter, preview)?;
|
||||
let environment = installed_tools.create_environment(package_name, interpreter, preview)?;
|
||||
|
||||
// At this point, we removed any existing environment, so we should remove any of its
|
||||
// executables.
|
||||
|
|
@ -587,8 +592,8 @@ pub(crate) async fn install(
|
|||
.await
|
||||
.inspect_err(|_| {
|
||||
// If we failed to sync, remove the newly created environment.
|
||||
debug!("Failed to sync environment; removing `{}`", from.name);
|
||||
let _ = installed_tools.remove_environment(&from.name);
|
||||
debug!("Failed to sync environment; removing `{}`", package_name);
|
||||
let _ = installed_tools.remove_environment(package_name);
|
||||
}) {
|
||||
Ok(environment) => environment,
|
||||
Err(ProjectError::Operation(err)) => {
|
||||
|
|
@ -602,9 +607,10 @@ pub(crate) async fn install(
|
|||
|
||||
finalize_tool_install(
|
||||
&environment,
|
||||
&from.name,
|
||||
package_name,
|
||||
entrypoints,
|
||||
&installed_tools,
|
||||
options,
|
||||
&options,
|
||||
force || invalid_tool_receipt,
|
||||
python_request,
|
||||
requirements,
|
||||
|
|
@ -612,5 +618,7 @@ pub(crate) async fn install(
|
|||
overrides,
|
||||
build_constraints,
|
||||
printer,
|
||||
)
|
||||
)?;
|
||||
|
||||
Ok(ExitStatus::Success)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use itertools::Itertools;
|
|||
use owo_colors::{AnsiColors, OwoColorize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write;
|
||||
use std::str::FromStr;
|
||||
use tracing::debug;
|
||||
|
||||
use uv_cache::Cache;
|
||||
|
|
@ -372,12 +373,19 @@ async fn upgrade_tool(
|
|||
// existing executables.
|
||||
remove_entrypoints(&existing_tool_receipt);
|
||||
|
||||
let entrypoints: Vec<_> = existing_tool_receipt
|
||||
.entrypoints()
|
||||
.iter()
|
||||
.filter_map(|entry| PackageName::from_str(entry.from.as_ref()?).ok())
|
||||
.collect();
|
||||
|
||||
// If we modified the target tool, reinstall the entrypoints.
|
||||
finalize_tool_install(
|
||||
&environment,
|
||||
name,
|
||||
&entrypoints,
|
||||
installed_tools,
|
||||
ToolOptions::from(options),
|
||||
&ToolOptions::from(options),
|
||||
true,
|
||||
existing_tool_receipt.python().to_owned(),
|
||||
existing_tool_receipt.requirements().to_vec(),
|
||||
|
|
|
|||
|
|
@ -1258,21 +1258,35 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
|
|||
.combine(Refresh::from(args.settings.resolver.upgrade.clone())),
|
||||
);
|
||||
|
||||
let mut entrypoints = Vec::with_capacity(args.with_executables_from.len());
|
||||
let mut requirements = Vec::with_capacity(
|
||||
args.with.len() + args.with_editable.len() + args.with_requirements.len(),
|
||||
args.with.len()
|
||||
+ args.with_editable.len()
|
||||
+ args.with_requirements.len()
|
||||
+ args.with_executables_from.len(),
|
||||
);
|
||||
for package in args.with {
|
||||
requirements.push(RequirementsSource::from_with_package_argument(&package)?);
|
||||
for pkg in args.with {
|
||||
requirements.push(RequirementsSource::from_with_package_argument(&pkg)?);
|
||||
}
|
||||
for package in args.with_editable {
|
||||
requirements.push(RequirementsSource::from_editable(&package)?);
|
||||
for pkg in args.with_editable {
|
||||
requirements.push(RequirementsSource::from_editable(&pkg)?);
|
||||
}
|
||||
for path in args.with_requirements {
|
||||
requirements.push(RequirementsSource::from_requirements_file(path)?);
|
||||
}
|
||||
for pkg in &args.with_executables_from {
|
||||
let source = RequirementsSource::from_with_package_argument(pkg)?;
|
||||
let RequirementsSource::Package(RequirementsTxtRequirement::Named(requirement)) =
|
||||
&source
|
||||
else {
|
||||
bail!(
|
||||
"Expected a named package for `--with-executables-from`, but got: {}",
|
||||
source.to_string().cyan()
|
||||
)
|
||||
};
|
||||
entrypoints.push(requirement.name.clone());
|
||||
requirements.push(source);
|
||||
}
|
||||
requirements.extend(
|
||||
args.with_requirements
|
||||
.into_iter()
|
||||
.map(RequirementsSource::from_requirements_file)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
);
|
||||
|
||||
let constraints = args
|
||||
.constraints
|
||||
|
|
@ -1298,6 +1312,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
|
|||
&constraints,
|
||||
&overrides,
|
||||
&build_constraints,
|
||||
&entrypoints,
|
||||
args.python,
|
||||
args.install_mirrors,
|
||||
args.force,
|
||||
|
|
|
|||
|
|
@ -598,6 +598,7 @@ pub(crate) struct ToolInstallSettings {
|
|||
pub(crate) from: Option<String>,
|
||||
pub(crate) with: Vec<String>,
|
||||
pub(crate) with_requirements: Vec<PathBuf>,
|
||||
pub(crate) with_executables_from: Vec<String>,
|
||||
pub(crate) with_editable: Vec<String>,
|
||||
pub(crate) constraints: Vec<PathBuf>,
|
||||
pub(crate) overrides: Vec<PathBuf>,
|
||||
|
|
@ -622,6 +623,7 @@ impl ToolInstallSettings {
|
|||
with,
|
||||
with_editable,
|
||||
with_requirements,
|
||||
with_executables_from,
|
||||
constraints,
|
||||
overrides,
|
||||
build_constraints,
|
||||
|
|
@ -662,6 +664,10 @@ impl ToolInstallSettings {
|
|||
.into_iter()
|
||||
.filter_map(Maybe::into_option)
|
||||
.collect(),
|
||||
with_executables_from: with_executables_from
|
||||
.into_iter()
|
||||
.flat_map(CommaSeparatedRequirements::into_iter)
|
||||
.collect(),
|
||||
constraints: constraints
|
||||
.into_iter()
|
||||
.filter_map(Maybe::into_option)
|
||||
|
|
|
|||
|
|
@ -3424,6 +3424,7 @@ fn resolve_tool() -> anyhow::Result<()> {
|
|||
from: None,
|
||||
with: [],
|
||||
with_requirements: [],
|
||||
with_executables_from: [],
|
||||
with_editable: [],
|
||||
constraints: [],
|
||||
overrides: [],
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ fn tool_install() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -168,7 +168,7 @@ fn tool_install() {
|
|||
[tool]
|
||||
requirements = [{ name = "flask" }]
|
||||
entrypoints = [
|
||||
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask" },
|
||||
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask", from = "flask" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -382,8 +382,8 @@ fn tool_install_with_compatible_build_constraints() -> Result<()> {
|
|||
]
|
||||
build-constraint-dependencies = [{ name = "setuptools", specifier = ">=40" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -450,7 +450,7 @@ fn tool_install_suggest_other_packages_with_executable() {
|
|||
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
|
||||
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
No executables are provided by package `fastapi`; removing tool
|
||||
hint: An executable with the name `fastapi` is available via dependency `fastapi-cli`.
|
||||
|
|
@ -494,6 +494,7 @@ fn tool_install_suggest_other_packages_with_executable() {
|
|||
+ uvicorn==0.29.0
|
||||
+ watchfiles==0.21.0
|
||||
+ websockets==12.0
|
||||
error: Failed to install entrypoints for `fastapi`
|
||||
");
|
||||
}
|
||||
|
||||
|
|
@ -565,8 +566,8 @@ fn tool_install_version() {
|
|||
[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" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -649,7 +650,7 @@ fn tool_install_editable() {
|
|||
[tool]
|
||||
requirements = [{ name = "black", editable = "[WORKSPACE]/scripts/packages/black_editable" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -690,7 +691,7 @@ fn tool_install_editable() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -733,8 +734,8 @@ fn tool_install_editable() {
|
|||
[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" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -781,8 +782,8 @@ fn tool_install_remove_on_empty() -> Result<()> {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -823,7 +824,7 @@ fn tool_install_remove_on_empty() -> Result<()> {
|
|||
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
|
||||
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
No executables are provided by package `black`; removing tool
|
||||
|
||||
|
|
@ -839,6 +840,7 @@ fn tool_install_remove_on_empty() -> Result<()> {
|
|||
- packaging==24.0
|
||||
- pathspec==0.12.1
|
||||
- platformdirs==4.2.0
|
||||
error: Failed to install entrypoints for `black`
|
||||
");
|
||||
|
||||
// Re-request `black`. It should reinstall, without requiring `--force`.
|
||||
|
|
@ -871,8 +873,8 @@ fn tool_install_remove_on_empty() -> Result<()> {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -949,7 +951,7 @@ fn tool_install_editable_from() {
|
|||
[tool]
|
||||
requirements = [{ name = "black", editable = "[WORKSPACE]/scripts/packages/black_editable" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -1101,8 +1103,8 @@ fn tool_install_already_installed() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -1137,8 +1139,8 @@ fn tool_install_already_installed() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -1428,8 +1430,8 @@ fn tool_install_force() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -1466,8 +1468,8 @@ fn tool_install_force() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -1651,7 +1653,7 @@ fn tool_install_no_entrypoints() {
|
|||
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
|
||||
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
No executables are provided by package `iniconfig`; removing tool
|
||||
|
||||
|
|
@ -1660,6 +1662,7 @@ fn tool_install_no_entrypoints() {
|
|||
Prepared 1 package in [TIME]
|
||||
Installed 1 package in [TIME]
|
||||
+ iniconfig==2.0.0
|
||||
error: Failed to install entrypoints for `iniconfig`
|
||||
");
|
||||
|
||||
// Ensure the tool environment is not created.
|
||||
|
|
@ -1794,8 +1797,8 @@ fn tool_install_unnamed_package() {
|
|||
[tool]
|
||||
requirements = [{ name = "black", url = "https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -1909,8 +1912,8 @@ fn tool_install_unnamed_from() {
|
|||
[tool]
|
||||
requirements = [{ name = "black", url = "https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2003,8 +2006,8 @@ fn tool_install_unnamed_with() {
|
|||
{ name = "iniconfig", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" },
|
||||
]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2072,8 +2075,8 @@ fn tool_install_requirements_txt() {
|
|||
{ name = "iniconfig" },
|
||||
]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2117,8 +2120,8 @@ fn tool_install_requirements_txt() {
|
|||
{ name = "idna" },
|
||||
]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2181,8 +2184,8 @@ fn tool_install_requirements_txt_arguments() {
|
|||
{ name = "idna" },
|
||||
]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2295,8 +2298,8 @@ fn tool_install_upgrade() {
|
|||
[tool]
|
||||
requirements = [{ name = "black", specifier = "==24.1.1" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2329,8 +2332,8 @@ fn tool_install_upgrade() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2369,8 +2372,8 @@ fn tool_install_upgrade() {
|
|||
{ name = "iniconfig", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" },
|
||||
]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2409,8 +2412,8 @@ fn tool_install_upgrade() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2878,7 +2881,7 @@ fn tool_install_malformed_dist_info() {
|
|||
[tool]
|
||||
requirements = [{ name = "executable-application" }]
|
||||
entrypoints = [
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2958,7 +2961,7 @@ fn tool_install_settings() {
|
|||
[tool]
|
||||
requirements = [{ name = "flask", specifier = ">=3" }]
|
||||
entrypoints = [
|
||||
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask" },
|
||||
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask", from = "flask" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -2991,7 +2994,7 @@ fn tool_install_settings() {
|
|||
[tool]
|
||||
requirements = [{ name = "flask", specifier = ">=3" }]
|
||||
entrypoints = [
|
||||
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask" },
|
||||
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask", from = "flask" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3031,7 +3034,7 @@ fn tool_install_settings() {
|
|||
[tool]
|
||||
requirements = [{ name = "flask", specifier = ">=3" }]
|
||||
entrypoints = [
|
||||
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask" },
|
||||
{ name = "flask", install-path = "[TEMP_DIR]/bin/flask", from = "flask" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3080,8 +3083,8 @@ fn tool_install_at_version() {
|
|||
[tool]
|
||||
requirements = [{ name = "black", specifier = "==24.1.0" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3146,8 +3149,8 @@ fn tool_install_at_latest() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3192,7 +3195,7 @@ fn tool_install_from_at_latest() {
|
|||
[tool]
|
||||
requirements = [{ name = "executable-application" }]
|
||||
entrypoints = [
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3237,7 +3240,7 @@ fn tool_install_from_at_version() {
|
|||
[tool]
|
||||
requirements = [{ name = "executable-application", specifier = "==0.2.0" }]
|
||||
entrypoints = [
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3286,8 +3289,8 @@ fn tool_install_at_latest_upgrade() {
|
|||
[tool]
|
||||
requirements = [{ name = "black", specifier = "==24.1.1" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3320,8 +3323,8 @@ fn tool_install_at_latest_upgrade() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3357,8 +3360,8 @@ fn tool_install_at_latest_upgrade() {
|
|||
[tool]
|
||||
requirements = [{ name = "black" }]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3419,8 +3422,8 @@ fn tool_install_constraints() -> Result<()> {
|
|||
{ name = "anyio", specifier = ">=3" },
|
||||
]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3526,8 +3529,8 @@ fn tool_install_overrides() -> Result<()> {
|
|||
{ name = "anyio", specifier = ">=3" },
|
||||
]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3700,7 +3703,7 @@ fn tool_install_credentials() {
|
|||
[tool]
|
||||
requirements = [{ name = "executable-application" }]
|
||||
entrypoints = [
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3789,7 +3792,7 @@ fn tool_install_default_credentials() -> Result<()> {
|
|||
[tool]
|
||||
requirements = [{ name = "executable-application" }]
|
||||
entrypoints = [
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app" },
|
||||
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -3832,3 +3835,136 @@ fn tool_install_default_credentials() -> Result<()> {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test installing a tool with `--with-executables-from`.
|
||||
#[test]
|
||||
fn tool_install_with_executables_from() {
|
||||
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");
|
||||
|
||||
uv_snapshot!(context.filters(), context.tool_install()
|
||||
.arg("--with-executables-from")
|
||||
.arg("ansible-core,black")
|
||||
.arg("ansible==9.3.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 -----
|
||||
Resolved [N] packages in [TIME]
|
||||
Prepared [N] packages in [TIME]
|
||||
Installed [N] packages in [TIME]
|
||||
+ ansible==9.3.0
|
||||
+ ansible-core==2.16.4
|
||||
+ black==24.3.0
|
||||
+ cffi==1.16.0
|
||||
+ click==8.1.7
|
||||
+ cryptography==42.0.5
|
||||
+ jinja2==3.1.3
|
||||
+ markupsafe==2.1.5
|
||||
+ mypy-extensions==1.0.0
|
||||
+ packaging==24.0
|
||||
+ pathspec==0.12.1
|
||||
+ platformdirs==4.2.0
|
||||
+ pycparser==2.21
|
||||
+ pyyaml==6.0.1
|
||||
+ resolvelib==1.0.1
|
||||
Installed 11 executables from `ansible-core`: ansible, ansible-config, ansible-connection, ansible-console, ansible-doc, ansible-galaxy, ansible-inventory, ansible-playbook, ansible-pull, ansible-test, ansible-vault
|
||||
Installed 2 executables from `black`: black, blackd
|
||||
Installed 1 executable: ansible-community
|
||||
");
|
||||
|
||||
insta::with_settings!({
|
||||
filters => context.filters(),
|
||||
}, {
|
||||
assert_snapshot!(fs_err::read_to_string(tool_dir.join("ansible").join("uv-receipt.toml")).unwrap(), @r###"
|
||||
[tool]
|
||||
requirements = [
|
||||
{ name = "ansible", specifier = "==9.3.0" },
|
||||
{ name = "ansible-core" },
|
||||
{ name = "black" },
|
||||
]
|
||||
entrypoints = [
|
||||
{ name = "ansible", install-path = "[TEMP_DIR]/bin/ansible", from = "ansible-core" },
|
||||
{ name = "ansible-community", install-path = "[TEMP_DIR]/bin/ansible-community", from = "ansible" },
|
||||
{ name = "ansible-config", install-path = "[TEMP_DIR]/bin/ansible-config", from = "ansible-core" },
|
||||
{ name = "ansible-connection", install-path = "[TEMP_DIR]/bin/ansible-connection", from = "ansible-core" },
|
||||
{ name = "ansible-console", install-path = "[TEMP_DIR]/bin/ansible-console", from = "ansible-core" },
|
||||
{ name = "ansible-doc", install-path = "[TEMP_DIR]/bin/ansible-doc", from = "ansible-core" },
|
||||
{ name = "ansible-galaxy", install-path = "[TEMP_DIR]/bin/ansible-galaxy", from = "ansible-core" },
|
||||
{ name = "ansible-inventory", install-path = "[TEMP_DIR]/bin/ansible-inventory", from = "ansible-core" },
|
||||
{ name = "ansible-playbook", install-path = "[TEMP_DIR]/bin/ansible-playbook", from = "ansible-core" },
|
||||
{ name = "ansible-pull", install-path = "[TEMP_DIR]/bin/ansible-pull", from = "ansible-core" },
|
||||
{ name = "ansible-test", install-path = "[TEMP_DIR]/bin/ansible-test", from = "ansible-core" },
|
||||
{ name = "ansible-vault", install-path = "[TEMP_DIR]/bin/ansible-vault", from = "ansible-core" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
exclude-newer = "2024-03-25T00:00:00Z"
|
||||
"###);
|
||||
});
|
||||
|
||||
uv_snapshot!(context.filters(), context.tool_uninstall()
|
||||
.arg("ansible")
|
||||
.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 -----
|
||||
Uninstalled 14 executables: ansible, ansible-community, ansible-config, ansible-connection, ansible-console, ansible-doc, ansible-galaxy, ansible-inventory, ansible-playbook, ansible-pull, ansible-test, ansible-vault, black, blackd
|
||||
"###);
|
||||
}
|
||||
|
||||
/// Test installing a tool with `--with-executables-from`, but the package has no entrypoints.
|
||||
#[test]
|
||||
fn tool_install_with_executables_from_no_entrypoints() {
|
||||
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");
|
||||
|
||||
// Try to install flask with executables from requests (which has no executables)
|
||||
uv_snapshot!(context.filters(), context.tool_install()
|
||||
.arg("--with-executables-from")
|
||||
.arg("requests")
|
||||
.arg("flask")
|
||||
.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 -----
|
||||
No executables are provided by package `requests`
|
||||
hint: Use `--with requests` to include `requests` as a dependency without installing its executables.
|
||||
|
||||
----- stderr -----
|
||||
Resolved [N] packages in [TIME]
|
||||
Prepared [N] packages in [TIME]
|
||||
Installed [N] packages in [TIME]
|
||||
+ blinker==1.7.0
|
||||
+ certifi==2024.2.2
|
||||
+ charset-normalizer==3.3.2
|
||||
+ click==8.1.7
|
||||
+ flask==3.0.2
|
||||
+ idna==3.6
|
||||
+ itsdangerous==2.1.2
|
||||
+ jinja2==3.1.3
|
||||
+ markupsafe==2.1.5
|
||||
+ requests==2.31.0
|
||||
+ urllib3==2.2.1
|
||||
+ werkzeug==3.0.1
|
||||
Installed 1 executable: flask
|
||||
"###);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,8 +89,8 @@ fn tool_list_paths_windows() {
|
|||
exit_code: 0
|
||||
----- stdout -----
|
||||
black v24.2.0 ([TEMP_DIR]\tools\black)
|
||||
- black.exe ([TEMP_DIR]\bin\black.exe)
|
||||
- blackd.exe ([TEMP_DIR]\bin\blackd.exe)
|
||||
- black ([TEMP_DIR]\bin\black.exe)
|
||||
- blackd ([TEMP_DIR]\bin\blackd.exe)
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
|
@ -218,8 +218,8 @@ fn tool_list_deprecated() -> Result<()> {
|
|||
[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" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
|
||||
[tool.options]
|
||||
|
|
@ -234,8 +234,8 @@ fn tool_list_deprecated() -> Result<()> {
|
|||
[tool]
|
||||
requirements = ["black==24.2.0"]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
"#,
|
||||
)?;
|
||||
|
|
@ -261,8 +261,8 @@ fn tool_list_deprecated() -> Result<()> {
|
|||
[tool]
|
||||
requirements = ["black<>24.2.0"]
|
||||
entrypoints = [
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" },
|
||||
{ name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
|
||||
{ name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
|
||||
]
|
||||
"#,
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -835,3 +835,71 @@ fn tool_upgrade_python_with_all() {
|
|||
assert_snapshot!(lines[lines.len() - 3], @"version_info = 3.12.[X]");
|
||||
});
|
||||
}
|
||||
|
||||
/// Upgrade a tool together with any additional entrypoints from other
|
||||
/// packages.
|
||||
#[test]
|
||||
fn test_tool_upgrade_additional_entrypoints() {
|
||||
let context = TestContext::new_with_versions(&["3.11", "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 `babel` entrypoint, and all additional ones from `black` too.
|
||||
uv_snapshot!(context.filters(), context.tool_install()
|
||||
.arg("--python")
|
||||
.arg("3.11")
|
||||
.arg("--with-executables-from")
|
||||
.arg("black")
|
||||
.arg("babel==2.14.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 -----
|
||||
Resolved [N] packages in [TIME]
|
||||
Prepared [N] packages in [TIME]
|
||||
Installed [N] packages in [TIME]
|
||||
+ babel==2.14.0
|
||||
+ 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 from `black`: black, blackd
|
||||
Installed 1 executable: pybabel
|
||||
");
|
||||
|
||||
// Upgrade python, and make sure that all the entrypoints above get
|
||||
// re-installed.
|
||||
uv_snapshot!(context.filters(), context.tool_upgrade()
|
||||
.arg("--python")
|
||||
.arg("3.12")
|
||||
.arg("babel")
|
||||
.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 -----
|
||||
Prepared [N] packages in [TIME]
|
||||
Installed [N] packages in [TIME]
|
||||
+ babel==2.14.0
|
||||
+ 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 from `black`: black, blackd
|
||||
Installed 1 executable: pybabel
|
||||
Upgraded tool environment for `babel` to Python 3.12
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,40 @@ $ uvx -w <extra-package> <tool-package>
|
|||
If the requested version conflicts with the requirements of the tool package, package resolution
|
||||
will fail and the command will error.
|
||||
|
||||
## Installing executables from additional packages
|
||||
|
||||
When installing a tool, you may want to include executables from additional packages in the same
|
||||
tool environment. This is useful when you have related tools that work together or when you want to
|
||||
install multiple executables that share dependencies.
|
||||
|
||||
The `--with-executables-from` option allows you to specify additional packages whose executables
|
||||
should be installed alongside the main tool:
|
||||
|
||||
```console
|
||||
$ uv tool install --with-executables-from <package1>,<package2> <tool-package>
|
||||
```
|
||||
|
||||
For example, to install Ansible along with executables from `ansible-core` and `ansible-lint`:
|
||||
|
||||
```console
|
||||
$ uv tool install --with-executables-from ansible-core,ansible-lint ansible
|
||||
```
|
||||
|
||||
This will install all executables from the `ansible`, `ansible-core`, and `ansible-lint` packages
|
||||
into the same tool environment, making them all available on the `PATH`.
|
||||
|
||||
The `--with-executables-from` option can be combined with other installation options:
|
||||
|
||||
```console
|
||||
$ uv tool install --with-executables-from ansible-core --with mkdocs-material ansible
|
||||
```
|
||||
|
||||
Note that `--with-executables-from` differs from `--with` in that:
|
||||
|
||||
- `--with` includes additional packages as dependencies but does not install their executables
|
||||
- `--with-executables-from` includes both the packages as dependencies and installs their
|
||||
executables
|
||||
|
||||
## Python versions
|
||||
|
||||
Each tool environment is linked to a specific Python version. This uses the same Python version
|
||||
|
|
|
|||
|
|
@ -213,6 +213,14 @@ As with `uvx`, installations can include additional packages:
|
|||
$ uv tool install mkdocs --with mkdocs-material
|
||||
```
|
||||
|
||||
Multiple related executables can be installed together in the same tool environment, using the
|
||||
`--with-executables-from` flag. For example, the following will install the executables from
|
||||
`ansible`, plus those ones provided by `ansible-core` and `ansible-lint`:
|
||||
|
||||
```console
|
||||
$ uv tool install --with-executables-from ansible-core,ansible-lint ansible
|
||||
```
|
||||
|
||||
## Upgrading tools
|
||||
|
||||
To upgrade a tool, use `uv tool upgrade`:
|
||||
|
|
|
|||
|
|
@ -2198,6 +2198,7 @@ uv tool install [OPTIONS] <PACKAGE>
|
|||
<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (<a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives">https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives</a>)</p>
|
||||
</dd><dt id="uv-tool-install--with"><a href="#uv-tool-install--with"><code>--with</code></a>, <code>-w</code> <i>with</i></dt><dd><p>Include the following additional requirements</p>
|
||||
</dd><dt id="uv-tool-install--with-editable"><a href="#uv-tool-install--with-editable"><code>--with-editable</code></a> <i>with-editable</i></dt><dd><p>Include the given packages in editable mode</p>
|
||||
</dd><dt id="uv-tool-install--with-executables-from"><a href="#uv-tool-install--with-executables-from"><code>--with-executables-from</code></a> <i>with-executables-from</i></dt><dd><p>Install executables from the following packages</p>
|
||||
</dd><dt id="uv-tool-install--with-requirements"><a href="#uv-tool-install--with-requirements"><code>--with-requirements</code></a> <i>with-requirements</i></dt><dd><p>Include all requirements listed in the given <code>requirements.txt</code> files</p>
|
||||
</dd></dl>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue