diff --git a/Cargo.lock b/Cargo.lock index 8d0405ba3..676310cc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5043,6 +5043,7 @@ dependencies = [ "unicode-width 0.2.1", "url", "uv-auth", + "uv-bin-install", "uv-build-backend", "uv-build-frontend", "uv-cache", @@ -5154,6 +5155,29 @@ dependencies = [ "uv-workspace", ] +[[package]] +name = "uv-bin-install" +version = "0.0.1" +dependencies = [ + "fs-err", + "futures", + "reqwest", + "reqwest-middleware", + "reqwest-retry", + "tempfile", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tracing", + "url", + "uv-cache", + "uv-client", + "uv-distribution-filename", + "uv-extract", + "uv-pep440", + "uv-platform", +] + [[package]] name = "uv-build" version = "0.8.12" diff --git a/Cargo.toml b/Cargo.toml index b067ca5f1..e949b7f46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ license = "MIT OR Apache-2.0" [workspace.dependencies] uv-auth = { path = "crates/uv-auth" } +uv-bin-install = { path = "crates/uv-bin-install" } uv-build-backend = { path = "crates/uv-build-backend" } uv-build-frontend = { path = "crates/uv-build-frontend" } uv-cache = { path = "crates/uv-cache" } diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml new file mode 100644 index 000000000..6de52683a --- /dev/null +++ b/crates/uv-bin-install/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "uv-bin-install" +version = "0.0.1" +edition = { workspace = true } +rust-version = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +repository = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +description = "Binary download and installation utilities for uv" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +uv-cache = { workspace = true } +uv-client = { workspace = true } +uv-distribution-filename = { workspace = true } +uv-extract = { workspace = true } +uv-pep440 = { workspace = true } +uv-platform = { workspace = true } +fs-err = { workspace = true, features = ["tokio"] } +futures = { workspace = true } +reqwest = { workspace = true } +reqwest-middleware = { workspace = true } +reqwest-retry = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + diff --git a/crates/uv-bin-install/src/lib.rs b/crates/uv-bin-install/src/lib.rs new file mode 100644 index 000000000..058c6f652 --- /dev/null +++ b/crates/uv-bin-install/src/lib.rs @@ -0,0 +1,431 @@ +//! Binary download and installation utilities for uv. +//! +//! These utilities are specifically for consuming distributions that are _not_ Python packages, +//! e.g., `ruff` (which does have a Python package, but also has standalone binaries on GitHub). + +use std::path::PathBuf; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::{Duration, SystemTime}; + +use futures::TryStreamExt; +use reqwest_retry::RetryPolicy; +use std::fmt; +use thiserror::Error; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio_util::compat::FuturesAsyncReadCompatExt; +use tracing::debug; +use url::Url; +use uv_distribution_filename::SourceDistExtension; + +use uv_cache::{Cache, CacheBucket, CacheEntry}; +use uv_client::{BaseClient, is_extended_transient_error}; +use uv_extract::{Error as ExtractError, stream}; +use uv_pep440::Version; +use uv_platform::Platform; + +/// Binary tools that can be installed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Binary { + Ruff, +} + +impl Binary { + /// Get the default version for this binary. + pub fn default_version(&self) -> Version { + match self { + // TODO(zanieb): Figure out a nice way to automate updating this + Self::Ruff => Version::new([0, 12, 5]), + } + } + + /// The name of the binary. + /// + /// See [`Binary::executable`] for the platform-specific executable name. + pub fn name(&self) -> &'static str { + match self { + Self::Ruff => "ruff", + } + } + + /// Get the download URL for a specific version and platform. + pub fn download_url( + &self, + version: &Version, + platform: &str, + format: ArchiveFormat, + ) -> Result { + match self { + Self::Ruff => { + let url = format!( + "https://github.com/astral-sh/ruff/releases/download/{version}/ruff-{platform}.{}", + format.extension() + ); + Url::parse(&url).map_err(|err| Error::UrlParse { url, source: err }) + } + } + } + + /// Get the executable name + pub fn executable(&self) -> String { + format!("{}{}", self.name(), std::env::consts::EXE_SUFFIX) + } +} + +impl fmt::Display for Binary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Archive formats for binary downloads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchiveFormat { + Zip, + TarGz, +} + +impl ArchiveFormat { + /// Get the file extension for this archive format. + pub fn extension(&self) -> &'static str { + match self { + Self::Zip => "zip", + Self::TarGz => "tar.gz", + } + } +} + +impl From for SourceDistExtension { + fn from(val: ArchiveFormat) -> Self { + match val { + ArchiveFormat::Zip => Self::Zip, + ArchiveFormat::TarGz => Self::TarGz, + } + } +} + +/// Errors that can occur during binary download and installation. +#[derive(Debug, Error)] +pub enum Error { + #[error("Failed to download from: {url}")] + Download { + url: Url, + #[source] + source: reqwest_middleware::Error, + }, + + #[error("Failed to parse URL: {url}")] + UrlParse { + url: String, + #[source] + source: url::ParseError, + }, + + #[error("Failed to extract archive")] + Extract { + #[source] + source: ExtractError, + }, + + #[error("Binary not found in archive at expected location: {expected}")] + BinaryNotFound { expected: PathBuf }, + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("Failed to detect platform")] + Platform(#[from] uv_platform::Error), + + #[error("Attempt failed after {retries} retries")] + RetriedError { + #[source] + err: Box, + retries: u32, + }, +} + +impl Error { + /// Return the number of attempts that were made to complete this request before this error was + /// returned. Note that e.g. 3 retries equates to 4 attempts. + fn attempts(&self) -> u32 { + if let Self::RetriedError { retries, .. } = self { + return retries + 1; + } + 1 + } +} + +/// Install the given binary. +pub async fn bin_install( + binary: Binary, + version: &Version, + client: &BaseClient, + cache: &Cache, + reporter: &dyn Reporter, +) -> Result { + let platform = Platform::from_env()?; + let platform_name = platform.as_cargo_dist_triple(); + + // Check the cache first + let cache_entry = CacheEntry::new( + cache + .bucket(CacheBucket::Binaries) + .join(binary.name()) + .join(version.to_string()) + .join(&platform_name), + binary.executable(), + ); + + if cache_entry.path().exists() { + return Ok(cache_entry.into_path_buf()); + } + + let format = if platform.os.is_windows() { + ArchiveFormat::Zip + } else { + ArchiveFormat::TarGz + }; + + let download_url = binary.download_url(version, &platform_name, format)?; + + let cache_dir = cache_entry.dir(); + fs_err::tokio::create_dir_all(&cache_dir).await?; + + let path = download_and_unpack_with_retry( + binary, + version, + client, + cache, + reporter, + &platform_name, + format, + &download_url, + &cache_entry, + ) + .await?; + + // Add executable bit + #[cfg(unix)] + { + use std::fs::Permissions; + use std::os::unix::fs::PermissionsExt; + let permissions = fs_err::tokio::metadata(&path).await?.permissions(); + if permissions.mode() & 0o111 != 0o111 { + fs_err::tokio::set_permissions( + &path, + Permissions::from_mode(permissions.mode() | 0o111), + ) + .await?; + } + } + + Ok(path) +} + +/// Download and unpack a binary with retry on stream failures. +async fn download_and_unpack_with_retry( + binary: Binary, + version: &Version, + client: &BaseClient, + cache: &Cache, + reporter: &dyn Reporter, + platform_name: &str, + format: ArchiveFormat, + download_url: &Url, + cache_entry: &CacheEntry, +) -> Result { + let mut total_attempts = 0; + let mut retried_here = false; + let start_time = SystemTime::now(); + let retry_policy = client.retry_policy(); + + loop { + let result = download_and_unpack( + binary, + version, + client, + cache, + reporter, + platform_name, + format, + download_url, + cache_entry, + ) + .await; + + let result = match result { + Ok(path) => Ok(path), + Err(err) => { + total_attempts += err.attempts(); + let past_retries = total_attempts - 1; + + if is_extended_transient_error(&err) { + let retry_decision = retry_policy.should_retry(start_time, past_retries); + if let reqwest_retry::RetryDecision::Retry { execute_after } = retry_decision { + debug!( + "Transient failure while installing {} {}; retrying...", + binary.name(), + version + ); + let duration = execute_after + .duration_since(SystemTime::now()) + .unwrap_or_else(|_| Duration::default()); + tokio::time::sleep(duration).await; + retried_here = true; + continue; + } + } + + if retried_here { + Err(Error::RetriedError { + err: Box::new(err), + retries: past_retries, + }) + } else { + Err(err) + } + } + }; + return result; + } +} + +/// Download and unpackage a binary, +/// +/// NOTE [`download_and_unpack_with_retry`] should be used instead. +async fn download_and_unpack( + binary: Binary, + version: &Version, + client: &BaseClient, + cache: &Cache, + reporter: &dyn Reporter, + platform_name: &str, + format: ArchiveFormat, + download_url: &Url, + cache_entry: &CacheEntry, +) -> Result { + // Create a temporary directory for extraction + let temp_dir = tempfile::tempdir_in(cache.bucket(CacheBucket::Binaries))?; + + let response = client + .for_host(&download_url.clone().into()) + .get(download_url.clone()) + .send() + .await + .map_err(|err| Error::Download { + url: download_url.clone(), + source: err, + })?; + + let inner_retries = response + .extensions() + .get::() + .map(|retries| retries.value()); + + if let Err(status_error) = response.error_for_status_ref() { + let err = Error::Download { + url: download_url.clone(), + source: reqwest_middleware::Error::from(status_error), + }; + if let Some(retries) = inner_retries { + return Err(Error::RetriedError { + err: Box::new(err), + retries, + }); + } + return Err(err); + } + + // Get the download size from headers if available + let size = response + .headers() + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|val| val.to_str().ok()) + .and_then(|val| val.parse::().ok()); + + // Stream download directly to extraction + let reader = response + .bytes_stream() + .map_err(std::io::Error::other) + .into_async_read() + .compat(); + + let id = reporter.on_download_start(binary.name(), version, size); + let mut progress_reader = ProgressReader::new(reader, id, reporter); + stream::archive(&mut progress_reader, format.into(), temp_dir.path()) + .await + .map_err(|e| Error::Extract { source: e })?; + reporter.on_download_complete(id); + + // Find the binary in the extracted files + let extracted_binary = match format { + ArchiveFormat::Zip => { + // Windows ZIP archives contain the binary directly in the root + temp_dir.path().join(binary.executable()) + } + ArchiveFormat::TarGz => { + // tar.gz archives contain the binary in a subdirectory + temp_dir + .path() + .join(format!("{}-{platform_name}", binary.name())) + .join(binary.executable()) + } + }; + + if !extracted_binary.exists() { + return Err(Error::BinaryNotFound { + expected: extracted_binary, + }); + } + + // Move the binary to its final location before the temp directory is dropped + fs_err::tokio::rename(&extracted_binary, cache_entry.path()).await?; + + Ok(cache_entry.path().to_path_buf()) +} + +/// Progress reporter for binary downloads. +pub trait Reporter: Send + Sync { + /// Called when a download starts. + fn on_download_start(&self, name: &str, version: &Version, size: Option) -> usize; + /// Called when download progress is made. + fn on_download_progress(&self, id: usize, inc: u64); + /// Called when a download completes. + fn on_download_complete(&self, id: usize); +} + +/// An asynchronous reader that reports progress as bytes are read. +struct ProgressReader<'a, R> { + reader: R, + index: usize, + reporter: &'a dyn Reporter, +} + +impl<'a, R> ProgressReader<'a, R> { + /// Create a new [`ProgressReader`] that wraps another reader. + fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self { + Self { + reader, + index, + reporter, + } + } +} + +impl AsyncRead for ProgressReader<'_, R> +where + R: AsyncRead + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.as_mut().reader) + .poll_read(cx, buf) + .map_ok(|()| { + self.reporter + .on_download_progress(self.index, buf.filled().len() as u64); + }) + } +} diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index 1eec4370b..553cf8c2a 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -987,6 +987,8 @@ pub enum CacheBucket { Environments, /// Cached Python downloads Python, + /// Downloaded tool binaries (e.g., Ruff). + Binaries, } impl CacheBucket { @@ -1010,6 +1012,7 @@ impl CacheBucket { Self::Builds => "builds-v0", Self::Environments => "environments-v2", Self::Python => "python-v0", + Self::Binaries => "binaries-v0", } } @@ -1116,7 +1119,8 @@ impl CacheBucket { | Self::Archive | Self::Builds | Self::Environments - | Self::Python => { + | Self::Python + | Self::Binaries => { // Nothing to do. } } @@ -1135,6 +1139,7 @@ impl CacheBucket { Self::Archive, Self::Builds, Self::Environments, + Self::Binaries, ] .iter() .copied() diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 18af2f7df..2bc1e13d4 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1006,6 +1006,21 @@ pub enum ProjectCommand { Export(ExportArgs), /// Display the project's dependency tree. Tree(TreeArgs), + /// Format Python code in the project. + /// + /// Formats Python code using the Ruff formatter. By default, all Python files in the project + /// are formatted. This command has the same behavior as running `ruff format` in the project + /// root. + /// + /// To check if files are formatted without modifying them, use `--check`. To see a diff of + /// formatting changes, use `--diff`. + /// + /// By default, Additional arguments can be passed to Ruff after `--`. + #[command( + after_help = "Use `uv help format` for more details.", + after_long_help = "" + )] + Format(FormatArgs), } /// A re-implementation of `Option`, used to avoid Clap's automatic `Option` flattening in @@ -4281,6 +4296,32 @@ pub struct ExportArgs { pub python: Option>, } +#[derive(Args)] +pub struct FormatArgs { + /// Check if files are formatted without applying changes. + #[arg(long)] + pub check: bool, + + /// Show a diff of formatting changes without applying them. + /// + /// Implies `--check`. + #[arg(long)] + pub diff: bool, + + /// The version of Ruff to use for formatting. + /// + /// By default, a version of Ruff pinned by uv will be used. + #[arg(long)] + pub version: Option, + + /// Additional arguments to pass to Ruff. + /// + /// For example, use `uv format -- --line-length 100` to set the line length or + /// `uv format -- src/module/foo.py` to format a specific file. + #[arg(last = true)] + pub extra_args: Vec, +} + #[derive(Args)] pub struct ToolNamespace { #[command(subcommand)] diff --git a/crates/uv-configuration/src/preview.rs b/crates/uv-configuration/src/preview.rs index b8a776051..59c680264 100644 --- a/crates/uv-configuration/src/preview.rs +++ b/crates/uv-configuration/src/preview.rs @@ -17,6 +17,7 @@ bitflags::bitflags! { const PACKAGE_CONFLICTS = 1 << 5; const EXTRA_BUILD_DEPENDENCIES = 1 << 6; const DETECT_MODULE_CONFLICTS = 1 << 7; + const FORMAT = 1 << 8; } } @@ -34,6 +35,7 @@ impl PreviewFeatures { Self::PACKAGE_CONFLICTS => "package-conflicts", Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies", Self::DETECT_MODULE_CONFLICTS => "detect-module-conflicts", + Self::FORMAT => "format", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -79,6 +81,7 @@ impl FromStr for PreviewFeatures { "package-conflicts" => Self::PACKAGE_CONFLICTS, "extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES, "detect-module-conflicts" => Self::DETECT_MODULE_CONFLICTS, + "format" => Self::FORMAT, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -253,6 +256,7 @@ mod tests { PreviewFeatures::DETECT_MODULE_CONFLICTS.flag_as_str(), "detect-module-conflicts" ); + assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format"); } #[test] diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index 3fba0493a..fe5b5061f 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -120,6 +120,49 @@ impl Platform { true } + + /// Convert this platform to a `cargo-dist` style triple string. + pub fn as_cargo_dist_triple(&self) -> String { + use target_lexicon::{ + Architecture, ArmArchitecture, OperatingSystem, Riscv64Architecture, X86_32Architecture, + }; + + let Self { os, arch, libc } = &self; + + let arch_name = match arch.family() { + // Special cases where Display doesn't match target triple + Architecture::X86_32(X86_32Architecture::I686) => "i686".to_string(), + Architecture::Riscv64(Riscv64Architecture::Riscv64) => "riscv64gc".to_string(), + _ => arch.to_string(), + }; + let vendor = match &**os { + OperatingSystem::Darwin(_) => "apple", + OperatingSystem::Windows => "pc", + _ => "unknown", + }; + let os_name = match &**os { + OperatingSystem::Darwin(_) => "darwin", + _ => &os.to_string(), + }; + + let abi = match (&**os, libc) { + (OperatingSystem::Windows, _) => Some("msvc".to_string()), + (OperatingSystem::Linux, Libc::Some(env)) => Some({ + // Special suffix for ARM with hardware float + if matches!(arch.family(), Architecture::Arm(ArmArchitecture::Armv7)) { + format!("{env}eabihf") + } else { + env.to_string() + } + }), + _ => None, + }; + + format!( + "{arch_name}-{vendor}-{os_name}{abi}", + abi = abi.map(|abi| format!("-{abi}")).unwrap_or_default() + ) + } } impl fmt::Display for Platform { diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 905ca1812..260669aca 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] uv-auth = { workspace = true } +uv-bin-install = { workspace = true } uv-build-backend = { workspace = true } uv-build-frontend = { workspace = true } uv-cache = { workspace = true } @@ -90,6 +91,7 @@ rkyv = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tar = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 405aad955..26f2b5b54 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -23,6 +23,7 @@ pub(crate) use pip::tree::pip_tree; pub(crate) use pip::uninstall::pip_uninstall; pub(crate) use project::add::add; pub(crate) use project::export::export; +pub(crate) use project::format::format; pub(crate) use project::init::{InitKind, InitProjectKind, init}; pub(crate) use project::lock::lock; pub(crate) use project::remove::remove; diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs new file mode 100644 index 000000000..96ddd59bd --- /dev/null +++ b/crates/uv/src/commands/project/format.rs @@ -0,0 +1,70 @@ +use std::str::FromStr; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +use uv_bin_install::{Binary, bin_install}; +use uv_cache::Cache; +use uv_client::BaseClientBuilder; +use uv_configuration::{Preview, PreviewFeatures}; +use uv_pep440::Version; +use uv_warnings::warn_user; + +use crate::child::run_to_completion; +use crate::commands::ExitStatus; +use crate::commands::reporters::BinaryDownloadReporter; +use crate::printer::Printer; +use crate::settings::NetworkSettings; + +/// Run the formatter. +pub(crate) async fn format( + check: bool, + diff: bool, + extra_args: Vec, + version: Option, + network_settings: NetworkSettings, + cache: Cache, + printer: Printer, + preview: Preview, +) -> Result { + // Check if the format feature is in preview + if !preview.is_enabled(PreviewFeatures::FORMAT) { + warn_user!( + "`uv format` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::FORMAT + ); + } + // Parse version if provided + let version = version.as_deref().map(Version::from_str).transpose()?; + + let client = BaseClientBuilder::new() + .retries_from_env()? + .connectivity(network_settings.connectivity) + .native_tls(network_settings.native_tls) + .allow_insecure_host(network_settings.allow_insecure_host.clone()) + .build(); + + // Get the path to Ruff, downloading it if necessary + let reporter = BinaryDownloadReporter::single(printer); + let default_version = Binary::Ruff.default_version(); + let version = version.as_ref().unwrap_or(&default_version); + let ruff_path = bin_install(Binary::Ruff, version, &client, &cache, &reporter) + .await + .context("Failed to install ruff {version}")?; + + let mut command = Command::new(&ruff_path); + command.arg("format"); + + if check { + command.arg("--check"); + } + if diff { + command.arg("--diff"); + } + + // Add any additional arguments passed after `--` + command.args(extra_args.iter()); + + let handle = command.spawn().context("Failed to spawn `ruff format`")?; + run_to_completion(handle).await +} diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 7f0df3aa4..e90f8e5eb 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -63,6 +63,7 @@ use crate::settings::{ pub(crate) mod add; pub(crate) mod environment; pub(crate) mod export; +pub(crate) mod format; pub(crate) mod init; mod install_target; pub(crate) mod lock; diff --git a/crates/uv/src/commands/reporters.rs b/crates/uv/src/commands/reporters.rs index 6bfbf0a74..74c21307b 100644 --- a/crates/uv/src/commands/reporters.rs +++ b/crates/uv/src/commands/reporters.rs @@ -832,3 +832,32 @@ impl ColorDisplay for BuildableSource<'_> { } } } + +pub(crate) struct BinaryDownloadReporter { + reporter: ProgressReporter, +} + +impl BinaryDownloadReporter { + /// Initialize a [`BinaryDownloadReporter`] for a single binary download. + pub(crate) fn single(printer: Printer) -> Self { + let multi_progress = MultiProgress::with_draw_target(printer.target()); + let root = multi_progress.add(ProgressBar::with_draw_target(Some(1), printer.target())); + let reporter = ProgressReporter::new(root, multi_progress, printer); + Self { reporter } + } +} + +impl uv_bin_install::Reporter for BinaryDownloadReporter { + fn on_download_start(&self, name: &str, version: &Version, size: Option) -> usize { + self.reporter + .on_request_start(Direction::Download, format!("{name} v{version}"), size) + } + + fn on_download_progress(&self, id: usize, inc: u64) { + self.reporter.on_request_progress(id, inc); + } + + fn on_download_complete(&self, id: usize) { + self.reporter.on_request_complete(Direction::Download, id); + } +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c2bda3b35..48c46788d 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2187,6 +2187,26 @@ async fn run_project( .boxed_local() .await } + ProjectCommand::Format(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::FormatSettings::resolve(args, filesystem); + show_settings!(args); + + // Initialize the cache. + let cache = cache.init()?; + + Box::pin(commands::format( + args.check, + args.diff, + args.extra_args, + args.version, + globals.network_settings, + cache, + printer, + globals.preview, + )) + .await + } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 5f366f3eb..f56a02e82 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -15,8 +15,8 @@ use uv_cli::{ ToolUninstallArgs, TreeArgs, VenvArgs, VersionArgs, VersionBump, VersionFormat, }; use uv_cli::{ - AuthorFrom, BuildArgs, ExportArgs, PublishArgs, PythonDirArgs, ResolverInstallerArgs, - ToolUpgradeArgs, + AuthorFrom, BuildArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs, + ResolverInstallerArgs, ToolUpgradeArgs, options::{flag, resolver_installer_options, resolver_options}, }; use uv_client::Connectivity; @@ -1873,6 +1873,34 @@ impl ExportSettings { } } +/// The resolved settings to use for a `format` invocation. +#[derive(Debug, Clone)] +pub(crate) struct FormatSettings { + pub(crate) check: bool, + pub(crate) diff: bool, + pub(crate) extra_args: Vec, + pub(crate) version: Option, +} + +impl FormatSettings { + /// Resolve the [`FormatSettings`] from the CLI and filesystem configuration. + pub(crate) fn resolve(args: FormatArgs, _filesystem: Option) -> Self { + let FormatArgs { + check, + diff, + extra_args, + version, + } = args; + + Self { + check, + diff, + extra_args, + version, + } + } +} + /// The resolved settings to use for a `pip compile` invocation. #[derive(Debug, Clone)] pub(crate) struct PipCompileSettings { diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 64b6d1e5e..8809906cf 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -996,6 +996,14 @@ impl TestContext { command } + /// Create a `uv format` command with options shared across scenarios. + pub fn format(&self) -> Command { + let mut command = Self::new_command(); + command.arg("format"); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv build` command with options shared across scenarios. pub fn build(&self) -> Command { let mut command = Self::new_command(); diff --git a/crates/uv/tests/it/format.rs b/crates/uv/tests/it/format.rs new file mode 100644 index 000000000..6898fc3aa --- /dev/null +++ b/crates/uv/tests/it/format.rs @@ -0,0 +1,273 @@ +use anyhow::Result; +use assert_fs::prelude::*; +use indoc::indoc; +use insta::assert_snapshot; + +use crate::common::{TestContext, uv_snapshot}; + +#[test] +fn format_project() -> Result<()> { + let context = TestContext::new_with_versions(&[]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Create an unformatted Python file + let main_py = context.temp_dir.child("main.py"); + main_py.write_str(indoc! {r#" + import sys + def hello(): + print( "Hello, World!" ) + if __name__=="__main__": + hello( ) + "#})?; + + uv_snapshot!(context.filters(), context.format(), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning. + "); + + // Check that the file was formatted + let formatted_content = fs_err::read_to_string(&main_py)?; + assert_snapshot!(formatted_content, @r#" + import sys + + + def hello(): + print("Hello, World!") + + + if __name__ == "__main__": + hello() + "#); + + Ok(()) +} + +#[test] +fn format_check() -> Result<()> { + let context = TestContext::new_with_versions(&[]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Create an unformatted Python file + let main_py = context.temp_dir.child("main.py"); + main_py.write_str(indoc! {r#" + def hello(): + print( "Hello, World!" ) + "#})?; + + uv_snapshot!(context.filters(), context.format().arg("--check"), @r" + success: false + exit_code: 1 + ----- stdout ----- + Would reformat: main.py + 1 file would be reformatted + + ----- stderr ----- + warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning. + "); + + // Verify the file wasn't modified + let content = fs_err::read_to_string(&main_py)?; + assert_snapshot!(content, @r#" + def hello(): + print( "Hello, World!" ) + "#); + + Ok(()) +} + +#[test] +fn format_diff() -> Result<()> { + let context = TestContext::new_with_versions(&[]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Create an unformatted Python file + let main_py = context.temp_dir.child("main.py"); + main_py.write_str(indoc! {r#" + def hello(): + print( "Hello, World!" ) + "#})?; + + uv_snapshot!(context.filters(), context.format().arg("--diff"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + --- main.py + +++ main.py + @@ -1,2 +1,2 @@ + -def hello(): + - print( "Hello, World!" ) + +def hello(): + + print("Hello, World!") + + + ----- stderr ----- + warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning. + 1 file would be reformatted + "#); + + // Verify the file wasn't modified + let content = fs_err::read_to_string(&main_py)?; + assert_snapshot!(content, @r#" + def hello(): + print( "Hello, World!" ) + "#); + + Ok(()) +} + +#[test] +fn format_with_ruff_args() -> Result<()> { + let context = TestContext::new_with_versions(&[]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Create a Python file with a long line + let main_py = context.temp_dir.child("main.py"); + main_py.write_str(indoc! {r#" + def hello(): + print("This is a very long line that should normally be wrapped by the formatter but we will configure it to have a longer line length") + "#})?; + + // Run format with custom line length + uv_snapshot!(context.filters(), context.format().arg("--").arg("main.py").arg("--line-length").arg("200"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file left unchanged + + ----- stderr ----- + warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning. + "); + + // Check that the line wasn't wrapped (since we set a long line length) + let formatted_content = fs_err::read_to_string(&main_py)?; + assert_snapshot!(formatted_content, @r#" + def hello(): + print("This is a very long line that should normally be wrapped by the formatter but we will configure it to have a longer line length") + "#); + + Ok(()) +} + +#[test] +fn format_specific_files() -> Result<()> { + let context = TestContext::new_with_versions(&[]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Create multiple unformatted Python files + let main_py = context.temp_dir.child("main.py"); + main_py.write_str(indoc! {r#" + def main(): + print( "Main" ) + "#})?; + + let utils_py = context.temp_dir.child("utils.py"); + utils_py.write_str(indoc! {r#" + def utils(): + print( "utils" ) + "#})?; + + uv_snapshot!(context.filters(), context.format().arg("--").arg("main.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning. + "); + + let main_content = fs_err::read_to_string(&main_py)?; + assert_snapshot!(main_content, @r#" + def main(): + print("Main") + "#); + + // Unchanged + let utils_content = fs_err::read_to_string(&utils_py)?; + assert_snapshot!(utils_content, @r#" + def utils(): + print( "utils" ) + "#); + + Ok(()) +} + +#[test] +fn format_version_option() -> Result<()> { + let context = TestContext::new_with_versions(&[]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = [] + "#})?; + + let main_py = context.temp_dir.child("main.py"); + main_py.write_str(indoc! {r" + def hello(): pass + "})?; + + // Run format with specific Ruff version + // TODO(zanieb): It'd be nice to assert on the version used here somehow? Maybe we should emit + // the version we're using to stderr? Alas there's not a way to get the Ruff version from the + // format command :) + uv_snapshot!(context.filters(), context.format().arg("--version").arg("0.8.2"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning. + "); + + Ok(()) +} diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 54c10e972..5231f70d5 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -25,6 +25,7 @@ fn help() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + format Format Python code in the project tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -105,6 +106,7 @@ fn help_flag() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + format Format Python code in the project tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -183,6 +185,7 @@ fn help_short_flag() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + format Format Python code in the project tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -880,6 +883,7 @@ fn help_unknown_subcommand() { lock export tree + format tool python pip @@ -907,6 +911,7 @@ fn help_unknown_subcommand() { lock export tree + format tool python pip @@ -963,6 +968,7 @@ fn help_with_global_option() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + format Format Python code in the project tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -1084,6 +1090,7 @@ fn help_with_no_pager() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + format Format Python code in the project tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 0a02b3c44..70e2de964 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -26,6 +26,9 @@ mod edit; #[cfg(all(feature = "python", feature = "pypi"))] mod export; +#[cfg(all(feature = "python", feature = "pypi"))] +mod format; + mod help; #[cfg(all(feature = "python", feature = "pypi", feature = "git"))] diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 5a71d14db..faae87628 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7720,7 +7720,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT, ), }, python_preference: Managed, @@ -7946,7 +7946,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT, ), }, python_preference: Managed, diff --git a/docs/concepts/preview.md b/docs/concepts/preview.md index 1789b5d65..986f7674b 100644 --- a/docs/concepts/preview.md +++ b/docs/concepts/preview.md @@ -69,6 +69,7 @@ The following preview features are available: [installing `python` and `python3` executables](./python-versions.md#installing-python-executables). - `python-upgrade`: Allows [transparent Python version upgrades](./python-versions.md#upgrading-python-versions). +- `format`: Allows using `uv format`. ## Disabling preview features diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6b054e93f..80277154d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -21,6 +21,7 @@ uv [OPTIONS]
uv lock

Update the project's lockfile

uv export

Export the project's lockfile to an alternate format

uv tree

Display the project's dependency tree

+
uv format

Format Python code in the project

uv tool

Run and install commands provided by Python packages

uv python

Manage Python versions and installations

uv pip

Manage Python packages with a pip-compatible interface

@@ -1841,6 +1842,81 @@ interpreter. Use --universal to display the tree for all platforms,

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)

+## uv format + +Format Python code in the project. + +Formats Python code using the Ruff formatter. By default, all Python files in the project are formatted. This command has the same behavior as running `ruff format` in the project root. + +To check if files are formatted without modifying them, use `--check`. To see a diff of formatting changes, use `--diff`. + +By default, Additional arguments can be passed to Ruff after `--`. + +

Usage

+ +``` +uv format [OPTIONS] [-- ...] +``` + +

Arguments

+ +
EXTRA_ARGS

Additional arguments to pass to Ruff.

+

For example, use uv format -- --line-length 100 to set the line length or uv format -- src/module/foo.py to format a specific file.

+
+ +

Options

+ +
--allow-insecure-host, --trusted-host allow-insecure-host

Allow insecure connections to a host.

+

Can be provided multiple times.

+

Expects to receive either a hostname (e.g., localhost), a host-port pair (e.g., localhost:8080), or a URL (e.g., https://localhost).

+

WARNING: Hosts included in this list will not be verified against the system's certificate store. Only use --allow-insecure-host in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.

+

May also be set with the UV_INSECURE_HOST environment variable.

--cache-dir cache-dir

Path to the cache directory.

+

Defaults to $XDG_CACHE_HOME/uv or $HOME/.cache/uv on macOS and Linux, and %LOCALAPPDATA%\uv\cache on Windows.

+

To view the location of the cache directory, run uv cache dir.

+

May also be set with the UV_CACHE_DIR environment variable.

--check

Check if files are formatted without applying changes

+
--color color-choice

Control the use of color in output.

+

By default, uv will automatically detect support for colors when writing to a terminal.

+

Possible values:

+
    +
  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • +
  • always: Enables colored output regardless of the detected environment
  • +
  • never: Disables colored output
  • +
--config-file config-file

The path to a uv.toml file to use for configuration.

+

While uv configuration can be included in a pyproject.toml file, it is not allowed in this context.

+

May also be set with the UV_CONFIG_FILE environment variable.

--diff

Show a diff of formatting changes without applying them.

+

Implies --check.

+
--directory directory

Change to the given directory prior to running the command.

+

Relative paths are resolved with the given directory as the base.

+

See --project to only change the project root directory.

+
--help, -h

Display the concise help for this command

+
--managed-python

Require use of uv-managed Python versions.

+

By default, uv prefers using Python versions it manages. However, it will use system Python versions if a uv-managed Python is not installed. This option disables use of system Python versions.

+

May also be set with the UV_MANAGED_PYTHON environment variable.

--native-tls

Whether to load TLS certificates from the platform's native certificate store.

+

By default, uv loads certificates from the bundled webpki-roots crate. The webpki-roots are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).

+

However, in some cases, you may want to use the platform's native certificate store, especially if you're relying on a corporate trust root (e.g., for a mandatory proxy) that's included in your system's certificate store.

+

May also be set with the UV_NATIVE_TLS environment variable.

--no-cache, --no-cache-dir, -n

Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation

+

May also be set with the UV_NO_CACHE environment variable.

--no-config

Avoid discovering configuration files (pyproject.toml, uv.toml).

+

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

+

May also be set with the UV_NO_CONFIG environment variable.

--no-managed-python

Disable use of uv-managed Python versions.

+

Instead, uv will search for a suitable Python version on the system.

+

May also be set with the UV_NO_MANAGED_PYTHON environment variable.

--no-progress

Hide all progress outputs.

+

For example, spinners or progress bars.

+

May also be set with the UV_NO_PROGRESS environment variable.

--no-python-downloads

Disable automatic downloads of Python.

+
--offline

Disable network access.

+

When disabled, uv will only use locally cached data and locally available files.

+

May also be set with the UV_OFFLINE environment variable.

--project project

Run the command within the given project directory.

+

All pyproject.toml, uv.toml, and .python-version files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (.venv).

+

Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.

+

See --directory to change the working directory entirely.

+

This setting has no effect when used in the uv pip interface.

+

May also be set with the UV_PROJECT environment variable.

--quiet, -q

Use quiet output.

+

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

+
--verbose, -v

Use verbose output.

+

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)

+
--version version

The version of Ruff to use for formatting.

+

By default, a version of Ruff pinned by uv will be used.

+
+ ## uv tool Run and install commands provided by Python packages