Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ extend-exclude = [
"ecosystem/**",
"scripts/**/*.in",
"crates/uv-build-frontend/src/pipreqs/mapping",
"crates/uv/src/commands/tool/top_packages.txt",
]
ignore-hidden = false

Expand Down
7 changes: 7 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5111,6 +5111,13 @@ pub struct ToolRunArgs {
#[arg(long)]
pub python_platform: Option<TargetTriple>,

/// Automatically approve all tool installations without prompting.
///
/// When enabled, skips confirmation prompts for installing uncached packages.
/// This is useful for CI/CD environments or when you trust all packages.
#[arg(long, value_parser = clap::builder::BoolishValueParser::new())]
pub approve_all_tool_installs: bool,

#[arg(long, hide = true)]
pub generate_shell_completion: Option<clap_complete_command::Shell>,
}
Expand Down
59 changes: 51 additions & 8 deletions crates/uv-client/src/cached_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,20 @@ use uv_cache::{CacheEntry, Freshness};
use uv_fs::write_atomic;
use uv_redacted::DisplaySafeUrl;

use std::sync::Arc;

use crate::BaseClient;
use crate::base_client::is_transient_network_error;
use crate::error::ProblemDetails;
use crate::error::{Error, ErrorKind, ProblemDetails};
use crate::{
Error, ErrorKind,
httpcache::{AfterResponse, BeforeRequest, CachePolicy, CachePolicyBuilder},
rkyvutil::OwnedArchive,
};

/// A hook function that is called before downloading a file.
/// Returns `Ok(true)` to proceed with download, `Ok(false)` to cancel, or `Err` on error.
pub type PreDownloadHook = Arc<dyn Fn(&DisplaySafeUrl) -> Result<bool, Error>>;

/// Extract problem details from an HTTP response if it has the correct content type
///
/// Note: This consumes the response body, so it should only be called when there's an error status.
Expand Down Expand Up @@ -120,6 +125,7 @@ where
}

/// Dispatch type: Either a cached client error or a (user specified) error from the callback
#[derive(Debug)]
pub enum CachedClientError<CallbackError: std::error::Error + 'static> {
Client {
retries: Option<u32>,
Expand Down Expand Up @@ -244,17 +250,45 @@ impl From<Freshness> for CacheControl<'_> {
///
/// Again unlike `http-cache`, the caller gets full control over the cache key with the assumption
/// that it's a file.
#[derive(Debug, Clone)]
pub struct CachedClient(BaseClient);
#[derive(Clone)]
pub struct CachedClient {
client: BaseClient,
/// Optional hook called before downloading a file. Returns `Ok(true)` to proceed with download,
/// `Ok(false)` to cancel, or `Err`.
pre_download_hook: Option<PreDownloadHook>,
}

impl std::fmt::Debug for CachedClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedClient")
.field("client", &self.client)
.field(
"pre_download_hook",
&self.pre_download_hook.as_ref().map(|_| "..."),
)
.finish()
}
}

impl CachedClient {
pub fn new(client: BaseClient) -> Self {
Self(client)
Self {
client,
pre_download_hook: None,
}
}

/// Create a new [`CachedClient`] with a pre-download hook.
pub fn with_pre_download_hook(client: BaseClient, hook: PreDownloadHook) -> Self {
Self {
client,
pre_download_hook: Some(hook),
}
}

/// The underlying [`BaseClient`] without caching.
pub fn uncached(&self) -> &BaseClient {
&self.0
&self.client
}

/// Make a cached request with a custom response transformation
Expand Down Expand Up @@ -560,7 +594,7 @@ impl CachedClient {
let url = DisplaySafeUrl::from_url(req.url().clone());
debug!("Sending revalidation request for: {url}");
let mut response = self
.0
.client
.execute(req)
.instrument(info_span!("revalidation_request", url = url.as_str()))
.await
Expand Down Expand Up @@ -628,10 +662,19 @@ impl CachedClient {
cache_control: CacheControl<'_>,
) -> Result<(Response, Option<Box<CachePolicy>>), Error> {
let url = DisplaySafeUrl::from_url(req.url().clone());

// Call pre-download hook if set, before downloading.
if let Some(ref hook) = self.pre_download_hook {
let proceed = hook(&url)?;
if !proceed {
return Err(ErrorKind::DownloadCancelled(url).into());
}
}

trace!("Sending fresh {} request for {}", req.method(), url);
let cache_policy_builder = CachePolicyBuilder::new(&req);
let mut response = self
.0
.client
.execute(req)
.await
.map_err(|err| ErrorKind::from_reqwest_middleware(url.clone(), err))?;
Expand Down
4 changes: 4 additions & 0 deletions crates/uv-client/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ pub enum ErrorKind {
#[error("Failed to fetch: `{0}`")]
WrappedReqwestError(DisplaySafeUrl, #[source] WrappedReqwestError),

/// Download was cancelled by the pre-download hook.
#[error("Download cancelled by user")]
DownloadCancelled(DisplaySafeUrl),

/// Add the number of failed retries to the error.
#[error("Request failed after {retries} {subject}", subject = if *retries > 1 { "retries" } else { "retry" })]
RequestWithRetries {
Expand Down
4 changes: 3 additions & 1 deletion crates/uv-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ pub use base_client::{
RedirectClientWithMiddleware, RequestBuilder, RetryParsingError, UvRetryableStrategy,
is_transient_network_error,
};
pub use cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy};
pub use cached_client::{
CacheControl, CachedClient, CachedClientError, DataWithCachePolicy, PreDownloadHook,
};
pub use error::{Error, ErrorKind, WrappedReqwestError};
pub use flat_index::{FlatIndexClient, FlatIndexEntries, FlatIndexEntry, FlatIndexError};
pub use linehaul::LineHaul;
Expand Down
34 changes: 30 additions & 4 deletions crates/uv-client/src/registry_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use uv_small_str::SmallString;
use uv_torch::TorchStrategy;

use crate::base_client::{BaseClientBuilder, ExtraMiddleware, RedirectPolicy};
use crate::cached_client::CacheControl;
use crate::cached_client::{CacheControl, PreDownloadHook};
use crate::flat_index::FlatIndexEntry;
use crate::html::SimpleDetailHTML;
use crate::remote_metadata::wheel_metadata_from_remote_zip;
Expand All @@ -48,13 +48,14 @@ use crate::{
};

/// A builder for an [`RegistryClient`].
#[derive(Debug, Clone)]
#[derive(Clone)]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to re-add a Debug implementation. The PreDownloadHook broke the automatic one.

pub struct RegistryClientBuilder<'a> {
index_locations: IndexLocations,
index_strategy: IndexStrategy,
torch_backend: Option<TorchStrategy>,
cache: Cache,
base_client_builder: BaseClientBuilder<'a>,
pre_download_hook: Option<PreDownloadHook>,
}

impl<'a> RegistryClientBuilder<'a> {
Expand All @@ -65,9 +66,26 @@ impl<'a> RegistryClientBuilder<'a> {
torch_backend: None,
cache,
base_client_builder,
pre_download_hook: None,
}
}

/// Set a pre-download hook that is called before downloading any file.
/// The hook receives the URL and returns `Ok(true)` to proceed with download,
/// `Ok(false)` to cancel, or `Err` on error.
#[must_use]
pub fn pre_download_hook(mut self, hook: PreDownloadHook) -> Self {
self.pre_download_hook = Some(hook);
self
}

/// Set a pre-download hook from a [`PreDownloadHook`].
#[must_use]
pub fn pre_download_hook_arc(mut self, hook: Option<PreDownloadHook>) -> Self {
self.pre_download_hook = hook;
self
}

#[must_use]
pub fn with_reqwest_client(mut self, client: reqwest::Client) -> Self {
self.base_client_builder = self.base_client_builder.custom_client(client);
Expand Down Expand Up @@ -164,7 +182,11 @@ impl<'a> RegistryClientBuilder<'a> {
let connectivity = client.connectivity();

// Wrap in the cache middleware.
let client = CachedClient::new(client);
let client = if let Some(hook) = self.pre_download_hook.clone() {
CachedClient::with_pre_download_hook(client, hook)
} else {
CachedClient::new(client)
};

RegistryClient {
index_urls,
Expand Down Expand Up @@ -194,7 +216,11 @@ impl<'a> RegistryClientBuilder<'a> {
let connectivity = client.connectivity();

// Wrap in the cache middleware.
let client = CachedClient::new(client);
let client = if let Some(hook) = self.pre_download_hook.clone() {
CachedClient::with_pre_download_hook(client, hook)
} else {
CachedClient::new(client)
};

RegistryClient {
index_urls,
Expand Down
3 changes: 3 additions & 0 deletions crates/uv-preview/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ bitflags::bitflags! {
const INIT_PROJECT_FLAG = 1 << 12;
const WORKSPACE_METADATA = 1 << 13;
const WORKSPACE_DIR = 1 << 14;
const TOOL_INSTALL_CONFIRMATION = 1 << 15;
}
}

Expand All @@ -48,6 +49,7 @@ impl PreviewFeatures {
Self::INIT_PROJECT_FLAG => "init-project-flag",
Self::WORKSPACE_METADATA => "workspace-metadata",
Self::WORKSPACE_DIR => "workspace-dir",
Self::TOOL_INSTALL_CONFIRMATION => "tool-install-confirmation",
_ => panic!("`flag_as_str` can only be used for exactly one feature flag"),
}
}
Expand Down Expand Up @@ -100,6 +102,7 @@ impl FromStr for PreviewFeatures {
"init-project-flag" => Self::INIT_PROJECT_FLAG,
"workspace-metadata" => Self::WORKSPACE_METADATA,
"workspace-dir" => Self::WORKSPACE_DIR,
"tool-install-confirmation" => Self::TOOL_INSTALL_CONFIRMATION,
_ => {
warn_user_once!("Unknown preview feature: `{part}`");
continue;
Expand Down
11 changes: 10 additions & 1 deletion crates/uv-settings/src/combine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use uv_torch::TorchMode;
use uv_workspace::pyproject::ExtraBuildDependencies;
use uv_workspace::pyproject_mut::AddBoundsKind;

use crate::{FilesystemOptions, Options, PipOptions};
use crate::{FilesystemOptions, InstallPromptOptions, Options, PipOptions};

pub trait Combine {
/// Combine two values, preferring the values in `self`.
Expand Down Expand Up @@ -72,6 +72,15 @@ impl Combine for Option<PipOptions> {
}
}

impl Combine for Option<InstallPromptOptions> {
fn combine(self, other: Self) -> Self {
match (self, other) {
(Some(a), Some(b)) => Some(a.combine(b)),
(a, b) => a.or(b),
}
}
}

macro_rules! impl_combine_or {
($name:ident) => {
impl Combine for Option<$name> {
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-settings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ fn validate_uv_toml(path: &Path, options: &Options) -> Result<(), Error> {
publish: _,
add: _,
pip: _,
install_prompt: _,
cache_keys: _,
override_dependencies: _,
exclude_dependencies: _,
Expand Down Expand Up @@ -351,6 +352,7 @@ fn warn_uv_toml_masked_fields(options: &Options) {
},
add: AddOptions { add_bounds },
pip,
install_prompt: _,
cache_keys,
override_dependencies,
exclude_dependencies,
Expand Down
51 changes: 51 additions & 0 deletions crates/uv-settings/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ pub struct Options {
#[option_group]
pub pip: Option<PipOptions>,

#[option_group]
pub install_prompt: Option<InstallPromptOptions>,

/// The keys to consider when caching builds for the project.
///
/// Cache keys enable you to specify the files or directories that should trigger a rebuild when
Expand Down Expand Up @@ -1934,6 +1937,51 @@ impl From<ResolverInstallerSchema> for InstallerOptions {
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum InstallPromptHeuristic {
TopPackages,
PreviouslyInstalled,
UserAllowlist,
}

/// Settings for tool install confirmation prompts.
#[derive(Debug, Clone, Default, Deserialize, CombineOptions, OptionsMetadata)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct InstallPromptOptions {
/// Automatically approve all tool installations without prompting.
///
/// When enabled, `uvx` and `uv tool run` will skip confirmation prompts for installing
/// uncached packages. This is useful for CI/CD environments or when you trust all packages.
#[option(
default = "false",
value_type = "bool",
example = r#"
approve-all-tool-installs = true
"#
)]
pub approve_all_tool_installs: Option<bool>,
/// A list of heuristics to use when deciding whether to show a confirmation prompt.
///
/// Each heuristic checks a different condition. If all enabled heuristics pass (i.e., the
/// package matches all checks), the prompt is skipped. Available heuristics:
/// - `top-packages`: Skip prompt if package is in the top Python packages list
/// - `previously-installed`: Skip prompt if package has been previously approved (not yet implemented)
/// - `user-allowlist`: Skip prompt if package is in user's allowlist file (not yet implemented)
///
/// Defaults to `["top-packages"]`.
#[option(
default = r#"["top-packages"]"#,
value_type = "list[str]",
example = r#"
approve-all-heuristics = ["top-packages", "previously-installed"]
"#
)]
pub approve_all_heuristics: Option<Vec<InstallPromptHeuristic>>,
}

/// The options persisted alongside an installed tool.
///
/// A mirror of [`ResolverInstallerSchema`], without upgrades and reinstalls, which shouldn't be
Expand Down Expand Up @@ -2108,6 +2156,7 @@ pub struct OptionsWire {

pip: Option<PipOptions>,
cache_keys: Option<Vec<CacheKey>>,
install_prompt: Option<InstallPromptOptions>,

// NOTE(charlie): These fields are shared with `ToolUv` in
// `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
Expand Down Expand Up @@ -2182,6 +2231,7 @@ impl From<OptionsWire> for Options {
no_binary,
no_binary_package,
pip,
install_prompt,
cache_keys,
override_dependencies,
exclude_dependencies,
Expand Down Expand Up @@ -2256,6 +2306,7 @@ impl From<OptionsWire> for Options {
no_binary_package,
},
pip,
install_prompt,
cache_keys,
build_backend,
override_dependencies,
Expand Down
Loading
Loading