From 572907d63a71d13b030a478362143ae0ca599504 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 26 Jul 2025 12:24:30 -0500 Subject: [PATCH 01/19] Initial commit of `uv format` --- crates/uv-cli/src/lib.rs | 57 ++++ crates/uv/src/commands/mod.rs | 1 + crates/uv/src/commands/project/format.rs | 174 ++++++++++ crates/uv/src/commands/project/mod.rs | 1 + crates/uv/src/lib.rs | 27 ++ crates/uv/src/settings.rs | 45 ++- crates/uv/tests/it/common/mod.rs | 8 + crates/uv/tests/it/format.rs | 417 +++++++++++++++++++++++ crates/uv/tests/it/main.rs | 3 + 9 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 crates/uv/src/commands/project/format.rs create mode 100644 crates/uv/tests/it/format.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index f80abc06d0e26..61ae1a3f08706 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1004,6 +1004,21 @@ pub enum ProjectCommand { Export(ExportArgs), /// Display the project's dependency tree. Tree(TreeArgs), + /// Format Python source files using Ruff. + /// + /// Formats Python source files using the Ruff formatter. By default, all Python files + /// in the current directory and subdirectories are formatted. + /// + /// To check if files are formatted without modifying them, use `--check`. + /// To see a diff of formatting changes, use `--diff`. + /// + /// Additional arguments can be passed to Ruff after `--`. For example: + /// `uv format src/ -- --line-length 100` + #[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 @@ -4252,6 +4267,48 @@ 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, + + /// Files or directories to format. + /// + /// If no files are specified, the current directory is formatted. + #[arg(value_name = "FILES")] + pub files: Vec, + + /// The Python interpreter to use for running Ruff. + /// + /// By default, the first Python interpreter found in the PATH or the project's virtual + /// environment will be used. + /// + /// See `uv help python` for details on Python discovery and supported request formats. + #[arg( + long, + short, + env = EnvVars::UV_PYTHON, + verbatim_doc_comment, + help_heading = "Python options", + value_parser = parse_maybe_string, + )] + pub python: Option>, + + /// Additional arguments to pass to Ruff. + /// + /// Use `--` to separate these arguments from uv arguments. + /// For example: `uv format src/ -- --line-length 100` + #[command(subcommand)] + pub args: Option, +} + #[derive(Args)] pub struct ToolNamespace { #[command(subcommand)] diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 405aad955b041..26f2b5b547ab1 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 0000000000000..2b6d2c2198f02 --- /dev/null +++ b/crates/uv/src/commands/project/format.rs @@ -0,0 +1,174 @@ +use std::io::Write; +use std::path::PathBuf; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use tokio::process::Command; +use tracing::debug; + +use uv_cache::Cache; +use uv_cli::ExternalCommand; +use uv_client::BaseClientBuilder; +use uv_configuration::{Concurrency, Constraints, Preview}; +use uv_distribution_types::{Requirement, RequirementSource}; +use uv_normalize::PackageName; +use uv_pep440::VersionSpecifiers; +use uv_pep508::MarkerTree; +use uv_python::{ + EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, + PythonPreference, PythonRequest, +}; +use uv_requirements::RequirementsSpecification; +use uv_settings::PythonInstallMirrors; + +use crate::commands::project::environment::CachedEnvironment; +use crate::commands::project::{EnvironmentSpecification, PlatformState}; +use crate::commands::ExitStatus; +use crate::commands::pip::loggers::{SummaryInstallLogger, SummaryResolveLogger}; +use crate::commands::reporters::PythonDownloadReporter; +use crate::printer::Printer; +use crate::settings::{NetworkSettings, ResolverInstallerSettings}; + +/// Format Python source files using Ruff. +#[allow(clippy::fn_params_excessive_bools)] +pub(crate) async fn format( + check: bool, + diff: bool, + files: Vec, + args: Option, + python: Option, + install_mirrors: PythonInstallMirrors, + settings: ResolverInstallerSettings, + network_settings: NetworkSettings, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + installer_metadata: bool, + concurrency: Concurrency, + cache: Cache, + printer: Printer, + preview: Preview, +) -> Result { + // Create a Ruff requirement. + let ruff_requirement = Requirement { + name: PackageName::from_str("ruff")?, + extras: Box::new([]), + groups: Box::new([]), + marker: MarkerTree::default(), + source: RequirementSource::Registry { + specifier: VersionSpecifiers::empty(), + index: None, + conflict: None, + }, + origin: None, + }; + + // Get or create the Python environment. + let client_builder = BaseClientBuilder::new() + .retries_from_env()? + .connectivity(network_settings.connectivity) + .native_tls(network_settings.native_tls) + .allow_insecure_host(network_settings.allow_insecure_host.clone()); + + let reporter = PythonDownloadReporter::single(printer); + + let python_request = python.as_deref().map(PythonRequest::parse); + + // Discover an interpreter. + let interpreter = PythonInstallation::find_or_download( + python_request.as_ref(), + EnvironmentPreference::Any, + python_preference, + python_downloads, + &client_builder, + &cache, + Some(&reporter), + install_mirrors.python_install_mirror.as_deref(), + install_mirrors.pypy_install_mirror.as_deref(), + install_mirrors.python_downloads_json_url.as_deref(), + preview, + ) + .await? + .into_interpreter(); + + // Initialize shared state. + let state = PlatformState::default(); + + // Create a requirements specification with Ruff. + let spec = EnvironmentSpecification::from(RequirementsSpecification { + requirements: vec![ruff_requirement.into()], + constraints: vec![], + overrides: vec![], + ..Default::default() + }); + + // Create or reuse a cached environment. + let environment = CachedEnvironment::from_spec( + spec, + Constraints::default(), + &interpreter, + &settings, + &network_settings, + &state, + Box::new(SummaryResolveLogger), + Box::new(SummaryInstallLogger), + installer_metadata, + concurrency, + &cache, + printer, + preview, + ) + .await?; + + let environment: PythonEnvironment = environment.into(); + + // Construct the ruff format command. + let mut command = Command::new(environment.scripts().join("ruff")); + command.arg("format"); + + // Add check flag if requested. + if check { + command.arg("--check"); + } + + // Add diff flag if requested. + if diff { + command.arg("--diff"); + } + + // Add files or directories to format. + if files.is_empty() { + // If no files specified, format the current directory. + command.arg("."); + } else { + for file in &files { + command.arg(file); + } + } + + // Add any additional arguments passed after --. + if let Some(args) = args { + for arg in args.iter() { + command.arg(arg); + } + } + + debug!("Running ruff format command: {:?}", command); + + // Run the ruff format command. + let output = command.output().await.context("Failed to run ruff format")?; + + // Stream stdout and stderr. + if !output.stdout.is_empty() { + std::io::stdout().write_all(&output.stdout)?; + } + if !output.stderr.is_empty() { + std::io::stderr().write_all(&output.stderr)?; + } + + // Return the exit status. + if output.status.success() { + Ok(ExitStatus::Success) + } else { + Ok(ExitStatus::Failure) + } +} \ No newline at end of file diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 6b4be560bf5d4..8e91871e56f4a 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -62,6 +62,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/lib.rs b/crates/uv/src/lib.rs index b1c93744c6b48..37f9749898e60 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2213,6 +2213,33 @@ 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.files, + args.args, + args.python, + args.install_mirrors, + args.settings, + globals.network_settings, + globals.python_preference, + globals.python_downloads, + false, // installer_metadata + globals.concurrency, + cache, + printer, + globals.preview, + )) + .await + } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 2e26233570a4a..45f6414d1afac 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -15,7 +15,7 @@ use uv_cli::{ ToolUninstallArgs, TreeArgs, VenvArgs, VersionArgs, VersionBump, VersionFormat, }; use uv_cli::{ - AuthorFrom, BuildArgs, ExportArgs, PublishArgs, PythonDirArgs, ResolverInstallerArgs, + AuthorFrom, BuildArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs, ResolverInstallerArgs, ToolUpgradeArgs, options::{flag, resolver_installer_options, resolver_options}, }; @@ -1862,6 +1862,49 @@ 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) files: Vec, + pub(crate) args: Option, + pub(crate) python: Option, + pub(crate) install_mirrors: PythonInstallMirrors, + pub(crate) settings: ResolverInstallerSettings, +} + +impl FormatSettings { + /// Resolve the [`FormatSettings`] from the CLI and filesystem configuration. + pub(crate) fn resolve(args: FormatArgs, filesystem: Option) -> Self { + let FormatArgs { + check, + diff, + files, + args, + python, + } = args; + + let install_mirrors = filesystem + .clone() + .map(|fs| fs.install_mirrors.clone()) + .unwrap_or_default(); + + Self { + check, + diff, + files, + args, + python: python.and_then(Maybe::into_option), + install_mirrors, + settings: ResolverInstallerSettings::combine( + ResolverInstallerOptions::default(), + filesystem, + ), + } + } +} + /// 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 c894b70a0121f..ac96895ef39de 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -962,6 +962,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 0000000000000..086b771694c0b --- /dev/null +++ b/crates/uv/tests/it/format.rs @@ -0,0 +1,417 @@ +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("3.12"); + + 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( ) + "#})?; + + // Snapshot the original content + let original_content = std::fs::read_to_string(&main_py)?; + assert_snapshot!(original_content, @r#" + import sys + def hello(): + print( "Hello, World!" ) + if __name__=="__main__": + hello( ) + "#); + + // Run format + uv_snapshot!(context.filters(), context.format().arg("main.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + Installed 1 package in [TIME] + "); + + // Check that the file was formatted + let formatted_content = std::fs::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("3.12"); + + 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!" ) + "#})?; + + // Run format with --check + uv_snapshot!(context.filters(), context.format().arg("--check").arg("main.py"), @r" + success: false + exit_code: 1 + ----- stdout ----- + Would reformat: main.py + 1 file would be reformatted + + ----- stderr ----- + Installed 1 package in [TIME] + "); + + // Verify the file wasn't modified + let content = std::fs::read_to_string(&main_py)?; + assert_snapshot!(content, @r#" + def hello(): + print( "Hello, World!" ) + "#); + + Ok(()) +} + +#[test] +fn format_diff() -> Result<()> { + let context = TestContext::new("3.12"); + + 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!" ) + "#})?; + + // Run format with --diff + uv_snapshot!(context.filters(), context.format().arg("--diff").arg("main.py"), @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 ----- + Installed 1 package in [TIME] + 1 file would be reformatted + "#); + + // Verify the file wasn't modified + let content = std::fs::read_to_string(&main_py)?; + assert_snapshot!(content, @r#" + def hello(): + print( "Hello, World!" ) + "#); + + Ok(()) +} + +#[test] +fn format_with_args() -> Result<()> { + let context = TestContext::new("3.12"); + + 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("main.py").arg("--").arg("--line-length").arg("200"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file left unchanged + + ----- stderr ----- + Installed 1 package in [TIME] + "); + + // Check that the line wasn't wrapped (because we set a high line length) + let formatted_content = std::fs::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_multiple_files() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 util(): + return 42 + "#})?; + + // Run format on both files + uv_snapshot!(context.filters(), context.format().arg("main.py").arg("utils.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 2 files reformatted + + ----- stderr ----- + Installed 1 package in [TIME] + "); + + // Check that both files were formatted + let main_content = std::fs::read_to_string(&main_py)?; + assert_snapshot!(main_content, @r#" + def main(): + print("Main") + "#); + + let utils_content = std::fs::read_to_string(&utils_py)?; + assert_snapshot!(utils_content, @r#" + def util(): + return 42 + "#); + + Ok(()) +} + +#[test] +fn format_directory() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 subdirectory with Python files + let src_dir = context.temp_dir.child("src"); + src_dir.create_dir_all()?; + + let module_py = src_dir.child("module.py"); + module_py.write_str(indoc! {r#" + def func(): + pass + "#})?; + + // Run format on directory + uv_snapshot!(context.filters(), context.format().arg("src/"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + Installed 1 package in [TIME] + "); + + // Check that the file in the directory was formatted + let module_content = std::fs::read_to_string(&module_py)?; + assert_snapshot!(module_content, @r#" + def func(): + pass + "#); + + Ok(()) +} + +#[test] +fn format_no_files() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 + let main_py = context.temp_dir.child("main.py"); + main_py.write_str(indoc! {r#" + def hello(): + print( "Hello" ) + "#})?; + + // Run format without specifying files (should format current directory) + uv_snapshot!(context.filters(), context.format(), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + Installed 1 package in [TIME] + "); + + // Check that the file was formatted + let content = std::fs::read_to_string(&main_py)?; + assert_snapshot!(content, @r#" + def hello(): + print("Hello") + "#); + + Ok(()) +} + +#[test] +fn format_cache_reuse() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 Python file + let main_py = context.temp_dir.child("main.py"); + main_py.write_str(indoc! {r#" + def hello(): pass + "#})?; + + // First run - installs Ruff + uv_snapshot!(context.filters(), context.format().arg("main.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + Installed 1 package in [TIME] + "); + + // Modify the file again + main_py.write_str(indoc! {r#" + def goodbye(): pass + "#})?; + + // Second run - should reuse cached Ruff + uv_snapshot!(context.filters(), context.format().arg("main.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn format_python_option() -> Result<()> { + let context = TestContext::new_with_versions(&["3.11", "3.12"]); + + 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 Python version + uv_snapshot!(context.filters(), context.format().arg("--python").arg("3.11").arg("main.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 1 file reformatted + + ----- stderr ----- + Installed 1 package in [TIME] + "); + + Ok(()) +} diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 872c88d4be0f9..c103338d8b80e 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"))] From 85afe5f11021593088d5c0c7a42877fb30895690 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 14:32:13 -0500 Subject: [PATCH 02/19] Download Ruff binaries from GitHub --- Cargo.lock | 1 + crates/uv-cache/src/lib.rs | 7 +- crates/uv-cli/src/lib.rs | 18 +-- crates/uv/Cargo.toml | 2 + crates/uv/src/commands/project/format.rs | 156 +++++++++-------------- crates/uv/src/commands/project/mod.rs | 1 + crates/uv/src/lib.rs | 9 +- crates/uv/src/settings.rs | 20 +-- crates/uv/tests/it/format.rs | 15 +-- 9 files changed, 82 insertions(+), 147 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf19b6c4b7f9a..5b71619fd502d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4696,6 +4696,7 @@ dependencies = [ "serde_json", "similar", "tar", + "target-lexicon", "tempfile", "textwrap", "thiserror 2.0.12", diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index d16c6427cebd9..2547c3333d02c 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 61ae1a3f08706..0176745362ad4 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4285,21 +4285,11 @@ pub struct FormatArgs { #[arg(value_name = "FILES")] pub files: Vec, - /// The Python interpreter to use for running Ruff. + /// The version of Ruff to use for formatting. /// - /// By default, the first Python interpreter found in the PATH or the project's virtual - /// environment will be used. - /// - /// See `uv help python` for details on Python discovery and supported request formats. - #[arg( - long, - short, - env = EnvVars::UV_PYTHON, - verbatim_doc_comment, - help_heading = "Python options", - value_parser = parse_maybe_string, - )] - pub python: Option>, + /// By default, the latest version of Ruff will be used. + #[arg(long)] + pub version: Option, /// Additional arguments to pass to Ruff. /// diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index e188ecdf6b1b6..78d79ea778618 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -89,6 +89,8 @@ rkyv = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tar = { workspace = true } +target-lexicon = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index 2b6d2c2198f02..53348b038ddaf 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -1,6 +1,6 @@ -use std::io::Write; +use std::fmt::Write; +use std::io::Write as IoWrite; use std::path::PathBuf; -use std::str::FromStr; use anyhow::{Context, Result}; use tokio::process::Command; @@ -9,120 +9,75 @@ use tracing::debug; use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::BaseClientBuilder; -use uv_configuration::{Concurrency, Constraints, Preview}; -use uv_distribution_types::{Requirement, RequirementSource}; -use uv_normalize::PackageName; -use uv_pep440::VersionSpecifiers; -use uv_pep508::MarkerTree; -use uv_python::{ - EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, - PythonPreference, PythonRequest, -}; -use uv_requirements::RequirementsSpecification; -use uv_settings::PythonInstallMirrors; - -use crate::commands::project::environment::CachedEnvironment; -use crate::commands::project::{EnvironmentSpecification, PlatformState}; +use uv_python::platform::{Arch, Libc, Os}; + +use crate::commands::project::ruff_download::RuffDownload; use crate::commands::ExitStatus; -use crate::commands::pip::loggers::{SummaryInstallLogger, SummaryResolveLogger}; -use crate::commands::reporters::PythonDownloadReporter; use crate::printer::Printer; -use crate::settings::{NetworkSettings, ResolverInstallerSettings}; +use crate::settings::NetworkSettings; /// Format Python source files using Ruff. -#[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn format( check: bool, diff: bool, files: Vec, args: Option, - python: Option, - install_mirrors: PythonInstallMirrors, - settings: ResolverInstallerSettings, + version: Option, network_settings: NetworkSettings, - python_preference: PythonPreference, - python_downloads: PythonDownloads, - installer_metadata: bool, - concurrency: Concurrency, cache: Cache, printer: Printer, - preview: Preview, ) -> Result { - // Create a Ruff requirement. - let ruff_requirement = Requirement { - name: PackageName::from_str("ruff")?, - extras: Box::new([]), - groups: Box::new([]), - marker: MarkerTree::default(), - source: RequirementSource::Registry { - specifier: VersionSpecifiers::empty(), - index: None, - conflict: None, - }, - origin: None, + debug!("format command called with check={}, diff={}, files={:?}, version={:?}", check, diff, files, version); + // Check if we're in offline mode + if network_settings.connectivity.is_offline() && version.is_none() { + // In offline mode without a specific version, we can't determine the latest version + writeln!( + printer.stderr(), + "Ruff formatting is not available in offline mode without a specific version" + )?; + return Ok(ExitStatus::Failure); + } + + // Get current platform information + debug!("Getting platform information"); + let os = Os::from_env(); + let arch = Arch::from_env(); + let libc = if cfg!(target_env = "musl") { + Libc::Some(target_lexicon::Environment::Musl) + } else if cfg!(target_os = "linux") { + Libc::Some(target_lexicon::Environment::Gnu) + } else { + Libc::None }; + debug!("Platform: os={}, arch={}, libc={}", os, arch, libc); - // Get or create the Python environment. - let client_builder = BaseClientBuilder::new() + // Create HTTP client + debug!("Creating HTTP client"); + 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()); - - let reporter = PythonDownloadReporter::single(printer); - - let python_request = python.as_deref().map(PythonRequest::parse); - - // Discover an interpreter. - let interpreter = PythonInstallation::find_or_download( - python_request.as_ref(), - EnvironmentPreference::Any, - python_preference, - python_downloads, - &client_builder, + .allow_insecure_host(network_settings.allow_insecure_host.clone()) + .build(); + debug!("HTTP client created"); + + // Download or retrieve Ruff binary from cache + debug!("Calling RuffDownload::download"); + let ruff_path = RuffDownload::download( + version.as_deref(), + &os, + &arch, + &libc, + &client, &cache, - Some(&reporter), - install_mirrors.python_install_mirror.as_deref(), - install_mirrors.pypy_install_mirror.as_deref(), - install_mirrors.python_downloads_json_url.as_deref(), - preview, ) - .await? - .into_interpreter(); - - // Initialize shared state. - let state = PlatformState::default(); - - // Create a requirements specification with Ruff. - let spec = EnvironmentSpecification::from(RequirementsSpecification { - requirements: vec![ruff_requirement.into()], - constraints: vec![], - overrides: vec![], - ..Default::default() - }); - - // Create or reuse a cached environment. - let environment = CachedEnvironment::from_spec( - spec, - Constraints::default(), - &interpreter, - &settings, - &network_settings, - &state, - Box::new(SummaryResolveLogger), - Box::new(SummaryInstallLogger), - installer_metadata, - concurrency, - &cache, - printer, - preview, - ) - .await?; - - let environment: PythonEnvironment = environment.into(); + .await + .context("Failed to download Ruff")?; + debug!("Got ruff binary at: {}", ruff_path.display()); // Construct the ruff format command. - let mut command = Command::new(environment.scripts().join("ruff")); + debug!("Constructing ruff command with binary: {}", ruff_path.display()); + let mut command = Command::new(&ruff_path); command.arg("format"); // Add check flag if requested. @@ -152,10 +107,17 @@ pub(crate) async fn format( } } - debug!("Running ruff format command: {:?}", command); + debug!("Full ruff format command: {:?}", command); + debug!("About to execute command"); // Run the ruff format command. - let output = command.output().await.context("Failed to run ruff format")?; + debug!("Executing command.output()"); + let output = command.output().await + .map_err(|e| { + debug!("Command execution failed: {}", e); + anyhow::anyhow!("Failed to run ruff format at {}: {}", ruff_path.display(), e) + })?; + debug!("Command executed successfully, status: {}", output.status); // Stream stdout and stderr. if !output.stdout.is_empty() { @@ -167,8 +129,10 @@ pub(crate) async fn format( // Return the exit status. if output.status.success() { + debug!("Ruff format completed successfully"); Ok(ExitStatus::Success) } else { + debug!("Ruff format failed with non-zero exit code"); Ok(ExitStatus::Failure) } } \ No newline at end of file diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 8e91871e56f4a..a82807c962cba 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -68,6 +68,7 @@ mod install_target; pub(crate) mod lock; mod lock_target; pub(crate) mod remove; +pub(crate) mod ruff_download; pub(crate) mod run; pub(crate) mod sync; pub(crate) mod tree; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 37f9749898e60..43bc06fc0c541 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2226,17 +2226,10 @@ async fn run_project( args.diff, args.files, args.args, - args.python, - args.install_mirrors, - args.settings, + args.version, globals.network_settings, - globals.python_preference, - globals.python_downloads, - false, // installer_metadata - globals.concurrency, cache, printer, - globals.preview, )) .await } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 45f6414d1afac..5a64993ac9e3c 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1869,38 +1869,26 @@ pub(crate) struct FormatSettings { pub(crate) diff: bool, pub(crate) files: Vec, pub(crate) args: Option, - pub(crate) python: Option, - pub(crate) install_mirrors: PythonInstallMirrors, - pub(crate) settings: ResolverInstallerSettings, + pub(crate) version: Option, } impl FormatSettings { /// Resolve the [`FormatSettings`] from the CLI and filesystem configuration. - pub(crate) fn resolve(args: FormatArgs, filesystem: Option) -> Self { + pub(crate) fn resolve(args: FormatArgs, _filesystem: Option) -> Self { let FormatArgs { check, diff, files, args, - python, + version, } = args; - let install_mirrors = filesystem - .clone() - .map(|fs| fs.install_mirrors.clone()) - .unwrap_or_default(); - Self { check, diff, files, args, - python: python.and_then(Maybe::into_option), - install_mirrors, - settings: ResolverInstallerSettings::combine( - ResolverInstallerOptions::default(), - filesystem, - ), + version, } } } diff --git a/crates/uv/tests/it/format.rs b/crates/uv/tests/it/format.rs index 086b771694c0b..7e60e8ad1b66d 100644 --- a/crates/uv/tests/it/format.rs +++ b/crates/uv/tests/it/format.rs @@ -46,7 +46,6 @@ fn format_project() -> Result<()> { 1 file reformatted ----- stderr ----- - Installed 1 package in [TIME] "); // Check that the file was formatted @@ -95,7 +94,6 @@ fn format_check() -> Result<()> { 1 file would be reformatted ----- stderr ----- - Installed 1 package in [TIME] "); // Verify the file wasn't modified @@ -143,7 +141,6 @@ fn format_diff() -> Result<()> { ----- stderr ----- - Installed 1 package in [TIME] 1 file would be reformatted "#); @@ -185,7 +182,6 @@ fn format_with_args() -> Result<()> { 1 file left unchanged ----- stderr ----- - Installed 1 package in [TIME] "); // Check that the line wasn't wrapped (because we set a high line length) @@ -232,7 +228,6 @@ fn format_multiple_files() -> Result<()> { 2 files reformatted ----- stderr ----- - Installed 1 package in [TIME] "); // Check that both files were formatted @@ -282,7 +277,6 @@ fn format_directory() -> Result<()> { 1 file reformatted ----- stderr ----- - Installed 1 package in [TIME] "); // Check that the file in the directory was formatted @@ -323,7 +317,6 @@ fn format_no_files() -> Result<()> { 1 file reformatted ----- stderr ----- - Installed 1 package in [TIME] "); // Check that the file was formatted @@ -363,7 +356,6 @@ fn format_cache_reuse() -> Result<()> { 1 file reformatted ----- stderr ----- - Installed 1 package in [TIME] "); // Modify the file again @@ -385,7 +377,7 @@ fn format_cache_reuse() -> Result<()> { } #[test] -fn format_python_option() -> Result<()> { +fn format_version_option() -> Result<()> { let context = TestContext::new_with_versions(&["3.11", "3.12"]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -402,15 +394,14 @@ fn format_python_option() -> Result<()> { def hello(): pass "#})?; - // Run format with specific Python version - uv_snapshot!(context.filters(), context.format().arg("--python").arg("3.11").arg("main.py"), @r" + // Run format with specific Ruff version + uv_snapshot!(context.filters(), context.format().arg("--version").arg("0.8.2").arg("main.py"), @r" success: true exit_code: 0 ----- stdout ----- 1 file reformatted ----- stderr ----- - Installed 1 package in [TIME] "); Ok(()) From 572356c6826044ff6587bc6246db19b050c4c43c Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 26 Jul 2025 13:54:15 -0500 Subject: [PATCH 03/19] Move into a dedicated `uv-bin-install` crate --- Cargo.lock | 23 ++ Cargo.toml | 1 + crates/uv-bin-install/Cargo.toml | 36 +++ crates/uv-bin-install/src/download.rs | 46 ++++ crates/uv-bin-install/src/error.rs | 57 ++++ crates/uv-bin-install/src/lib.rs | 15 ++ crates/uv-bin-install/src/ruff.rs | 324 +++++++++++++++++++++++ crates/uv/Cargo.toml | 1 + crates/uv/src/commands/project/format.rs | 18 +- crates/uv/src/commands/project/mod.rs | 1 - 10 files changed, 509 insertions(+), 13 deletions(-) create mode 100644 crates/uv-bin-install/Cargo.toml create mode 100644 crates/uv-bin-install/src/download.rs create mode 100644 crates/uv-bin-install/src/error.rs create mode 100644 crates/uv-bin-install/src/lib.rs create mode 100644 crates/uv-bin-install/src/ruff.rs diff --git a/Cargo.lock b/Cargo.lock index 5b71619fd502d..89febfaefffd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4710,6 +4710,7 @@ dependencies = [ "unicode-width 0.2.1", "url", "uv-auth", + "uv-bin-install", "uv-build-backend", "uv-build-frontend", "uv-cache", @@ -4821,6 +4822,28 @@ dependencies = [ "uv-workspace", ] +[[package]] +name = "uv-bin-install" +version = "0.0.1" +dependencies = [ + "anyhow", + "async-trait", + "flate2", + "futures", + "reqwest", + "reqwest-middleware", + "tar", + "tempfile", + "thiserror 2.0.12", + "tokio", + "tracing", + "url", + "uv-cache", + "uv-client", + "uv-extract", + "uv-platform", +] + [[package]] name = "uv-build" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index 6e9742bc88202..84dee0f577137 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 0000000000000..d2b079e5ad464 --- /dev/null +++ b/crates/uv-bin-install/Cargo.toml @@ -0,0 +1,36 @@ +[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-extract = { workspace = true } +uv-platform = { workspace = true } + +anyhow = { workspace = true } +async-trait = { workspace = true } +flate2 = { workspace = true } +futures = { workspace = true } +reqwest = { workspace = true } +reqwest-middleware = { workspace = true } +tar = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } \ No newline at end of file diff --git a/crates/uv-bin-install/src/download.rs b/crates/uv-bin-install/src/download.rs new file mode 100644 index 0000000000000..8720f165c3883 --- /dev/null +++ b/crates/uv-bin-install/src/download.rs @@ -0,0 +1,46 @@ +use std::path::PathBuf; + +use async_trait::async_trait; + +use uv_cache::Cache; +use uv_client::BaseClient; +use uv_platform::{Arch, Libc, Os}; + +use crate::Result; + +/// Trait for downloading and caching binary tools. +#[async_trait] +pub trait BinaryDownloader: Send + Sync { + /// The name of the tool being downloaded. + fn tool_name(&self) -> &str; + + /// The default version to use when none is specified. + fn default_version(&self) -> &str; + + /// Map platform information to a tool-specific platform identifier. + fn platform_identifier(&self, os: &Os, arch: &Arch, libc: &Libc) -> Option; + + /// Get the download URL for a specific version and platform. + fn download_url(&self, version: &str, platform: &str) -> String; + + /// Get the archive extension for the platform. + fn archive_extension(&self, os: &Os) -> &str; + + /// Get the expected binary name within the archive. + fn binary_name(&self, os: &Os) -> &str; + + /// Get the expected directory structure within the archive. + /// Returns None if the binary is at the root of the archive. + fn archive_directory(&self, platform: &str) -> Option; + + /// Download the binary for the specified version and platform. + async fn download( + &self, + version: Option<&str>, + os: &Os, + arch: &Arch, + libc: &Libc, + client: &BaseClient, + cache: &Cache, + ) -> Result; +} \ No newline at end of file diff --git a/crates/uv-bin-install/src/error.rs b/crates/uv-bin-install/src/error.rs new file mode 100644 index 0000000000000..2c945ee75efb9 --- /dev/null +++ b/crates/uv-bin-install/src/error.rs @@ -0,0 +1,57 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Errors that can occur during binary download and installation. +#[derive(Debug, Error)] +pub enum Error { + /// Failed to download binary. + #[error("Failed to download {tool} {version} from {url}")] + Download { + tool: String, + version: String, + url: String, + #[source] + source: reqwest_middleware::Error, + }, + + /// Failed to parse download URL. + #[error("Failed to parse download URL: {url}")] + UrlParse { + url: String, + #[source] + source: url::ParseError, + }, + + /// Unsupported platform for binary download. + #[error("Unsupported platform for {tool}: {platform}")] + UnsupportedPlatform { tool: String, platform: String }, + + /// Failed to extract archive. + #[error("Failed to extract {tool} archive")] + Extract { + tool: String, + #[source] + source: anyhow::Error, + }, + + /// Binary not found in extracted archive. + #[error("Binary not found in {tool} archive at expected location: {expected}")] + BinaryNotFound { tool: String, expected: PathBuf }, + + /// Task join error. + #[error("Task join error")] + Join(#[from] tokio::task::JoinError), + + /// I/O error during installation. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// Platform detection error. + #[error("Failed to detect platform")] + Platform(#[from] uv_platform::Error), + + /// Generic errors. + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} \ No newline at end of file diff --git a/crates/uv-bin-install/src/lib.rs b/crates/uv-bin-install/src/lib.rs new file mode 100644 index 0000000000000..8e44d29aee78e --- /dev/null +++ b/crates/uv-bin-install/src/lib.rs @@ -0,0 +1,15 @@ +//! Binary download and installation utilities for uv. +//! +//! This crate provides functionality for downloading and caching binary tools +//! from various sources (GitHub releases, etc.) for use by uv. + +pub use download::BinaryDownloader; +pub use error::Error; +pub use ruff::RuffDownloader; + +pub mod download; +pub mod error; +pub mod ruff; + +/// Result type for binary installation operations. +pub type Result = std::result::Result; \ No newline at end of file diff --git a/crates/uv-bin-install/src/ruff.rs b/crates/uv-bin-install/src/ruff.rs new file mode 100644 index 0000000000000..de44c4ace3d93 --- /dev/null +++ b/crates/uv-bin-install/src/ruff.rs @@ -0,0 +1,324 @@ +use std::path::PathBuf; +use std::str::FromStr; + +use async_trait::async_trait; +use futures::TryStreamExt; +use tokio::io::AsyncWriteExt; +use tracing::debug; +use url::Url; + +use uv_cache::{Cache, CacheBucket, CacheEntry}; +use uv_client::BaseClient; +use uv_extract::unzip; +use uv_platform::{Arch, Libc, Os}; + +use crate::download::BinaryDownloader; +use crate::error::Error; + +/// Download and cache Ruff binaries. +pub struct RuffDownloader; + +impl RuffDownloader { + /// Default Ruff version to use when none is specified. + /// This should be updated when UV is released to ensure compatibility. + const DEFAULT_VERSION: &'static str = "0.12.5"; + + /// Map UV's platform types to Ruff's binary naming convention. + fn platform_to_ruff_name(os: &Os, arch: &Arch, libc: &Libc) -> Option<&'static str> { + // Convert to string representations for matching + // This is a workaround until we have a dedicated uv-platform crate + let os_str = os.to_string(); + let arch_str = arch.to_string(); + let libc_str = libc.to_string(); + + match (os_str.as_str(), arch_str.as_str(), libc_str.as_str()) { + // macOS + ("macos", "x86_64", _) => Some("x86_64-apple-darwin"), + ("macos", "aarch64", _) => Some("aarch64-apple-darwin"), + + // Windows + ("windows", "x86_64", _) => Some("x86_64-pc-windows-msvc"), + ("windows", "aarch64", _) => Some("aarch64-pc-windows-msvc"), + ("windows", "x86", _) => Some("i686-pc-windows-msvc"), + + // Linux with glibc + ("linux", "x86_64", "gnu") => Some("x86_64-unknown-linux-gnu"), + ("linux", "aarch64", "gnu") => Some("aarch64-unknown-linux-gnu"), + ("linux", "x86", "gnu") => Some("i686-unknown-linux-gnu"), + ("linux", "arm", "gnu") => Some("armv7-unknown-linux-gnueabihf"), + ("linux", "s390x", "gnu") => Some("s390x-unknown-linux-gnu"), + ("linux", "powerpc64", "gnu") => Some("powerpc64-unknown-linux-gnu"), + ("linux", "powerpc64le", "gnu") => Some("powerpc64le-unknown-linux-gnu"), + ("linux", "riscv64", "gnu") => Some("riscv64gc-unknown-linux-gnu"), + + // Linux with musl + ("linux", "x86_64", "musl") => Some("x86_64-unknown-linux-musl"), + ("linux", "aarch64", "musl") => Some("aarch64-unknown-linux-musl"), + ("linux", "x86", "musl") => Some("i686-unknown-linux-musl"), + ("linux", "arm", "musl") => Some("armv7-unknown-linux-musleabihf"), + + _ => None, + } + } + + /// Get the file extension for the platform. + fn get_archive_extension(os: &Os) -> &'static str { + match &**os { + // Check if it's Windows by looking at the Display output + os if os.to_string().contains("windows") => ".zip", + _ => ".tar.gz", + } + } +} + +#[async_trait] +impl BinaryDownloader for RuffDownloader { + fn tool_name(&self) -> &str { + "ruff" + } + + fn default_version(&self) -> &str { + Self::DEFAULT_VERSION + } + + fn platform_identifier(&self, os: &Os, arch: &Arch, libc: &Libc) -> Option { + Self::platform_to_ruff_name(os, arch, libc).map(String::from) + } + + fn download_url(&self, version: &str, platform: &str) -> String { + let archive_extension = if platform.contains("windows") { + ".zip" + } else { + ".tar.gz" + }; + let archive_name = format!("ruff-{}{}", platform, archive_extension); + format!( + "https://github.com/astral-sh/ruff/releases/download/{}/{}", + version, archive_name + ) + } + + fn archive_extension(&self, os: &Os) -> &str { + Self::get_archive_extension(os) + } + + fn binary_name(&self, os: &Os) -> &str { + if os.to_string().contains("windows") { + "ruff.exe" + } else { + "ruff" + } + } + + fn archive_directory(&self, platform: &str) -> Option { + Some(format!("ruff-{}", platform)) + } + + async fn download( + &self, + version: Option<&str>, + os: &Os, + arch: &Arch, + libc: &Libc, + client: &BaseClient, + cache: &Cache, + ) -> crate::Result { + debug!("RuffDownloader::download called with version: {:?}", version); + // Get version to download + let version = if let Some(v) = version { + v.to_string() + } else { + self.default_version().to_string() + }; + + // Get platform-specific binary name + debug!("Getting platform name for os={}, arch={}, libc={}", os, arch, libc); + let platform_name = self.platform_identifier(os, arch, libc) + .ok_or_else(|| Error::UnsupportedPlatform { + tool: self.tool_name().to_string(), + platform: format!("{:?}-{:?}", os, arch), + })?; + debug!("Platform name: {}", platform_name); + + let archive_extension = self.archive_extension(os); + let archive_name = format!("ruff-{}{}", platform_name, archive_extension); + debug!("Archive name: {}", archive_name); + + // Check cache first + debug!("Creating cache entry"); + let cache_entry = CacheEntry::new( + cache.bucket(CacheBucket::ToolBinaries).join(self.tool_name()).join(&version).join(&platform_name), + self.binary_name(os), + ); + debug!("Cache entry created at: {}", cache_entry.path().display()); + + if cache_entry.path().exists() { + debug!("Using cached Ruff binary at {}", cache_entry.path().display()); + return Ok(cache_entry.into_path_buf()); + } + + debug!("Cache entry path: {}", cache_entry.path().display()); + debug!("Cache dir: {}", cache_entry.dir().display()); + + // Download URL + let download_url = self.download_url(&version, &platform_name); + + debug!("Downloading Ruff {} from {}", version, download_url); + + // Create cache directory first + let cache_bucket_dir = cache.bucket(CacheBucket::ToolBinaries); + debug!("Creating cache bucket dir: {}", cache_bucket_dir.display()); + tokio::fs::create_dir_all(&cache_bucket_dir).await + .map_err(|e| anyhow::anyhow!("Failed to create cache bucket dir {}: {}", cache_bucket_dir.display(), e))?; + debug!("Cache bucket dir created successfully"); + + // Download to temporary file + debug!("Creating temp dir in: {}", cache_bucket_dir.display()); + let temp_dir = tempfile::tempdir_in(&cache_bucket_dir) + .map_err(|e| anyhow::anyhow!("Failed to create temp dir in {}: {}", cache_bucket_dir.display(), e))?; + debug!("Temp dir created at: {}", temp_dir.path().display()); + let archive_path = temp_dir.path().join(&archive_name); + debug!("Archive will be downloaded to: {}", archive_path.display()); + + debug!("Sending HTTP request to download URL"); + let response = client + .for_host(&Url::parse(&download_url).map_err(|e| Error::UrlParse { + url: download_url.clone(), + source: e, + })?.into()) + .get(reqwest::Url::from_str(&download_url).map_err(|e| Error::UrlParse { + url: download_url.clone(), + source: e, + })?) + .send() + .await + .map_err(|e| Error::Download { + tool: self.tool_name().to_string(), + version: version.clone(), + url: download_url.clone(), + source: e, + })?; + debug!("HTTP response received: status={}", response.status()); + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to download Ruff: {} returned {}", + download_url, + response.status() + ).into()); + } + + // Write to file + debug!("Creating archive file at: {}", archive_path.display()); + let mut file = tokio::fs::File::create(&archive_path).await + .map_err(|e| anyhow::anyhow!("Failed to create archive file {}: {}", archive_path.display(), e))?; + debug!("Archive file created, starting download"); + let stream = response + .bytes_stream() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)); + + futures::pin_mut!(stream); + while let Some(chunk) = stream.try_next().await? { + file.write_all(&chunk).await?; + } + file.flush().await?; + drop(file); + debug!("Download complete, file written to: {}", archive_path.display()); + + // Extract archive + let extracted_dir = temp_dir.path().join("extracted"); + debug!("Creating extraction directory: {}", extracted_dir.display()); + tokio::fs::create_dir_all(&extracted_dir).await + .map_err(|e| anyhow::anyhow!("Failed to create extracted dir {}: {}", extracted_dir.display(), e))?; + debug!("Extraction directory created"); + + // Extract based on file extension + debug!("Starting extraction for archive type: {}", archive_extension); + if archive_name.ends_with(".zip") { + let file = std::fs::File::open(&archive_path)?; + tokio::task::spawn_blocking({ + let extracted_dir = extracted_dir.clone(); + move || unzip(file, &extracted_dir) + }) + .await? + .map_err(|e| Error::Extract { + tool: self.tool_name().to_string(), + source: e.into(), + })?; + debug!("ZIP extraction complete"); + } else { + debug!("Extracting tar.gz archive"); + // For .tar.gz files, we need to extract manually + let file = std::fs::File::open(&archive_path) + .map_err(|e| anyhow::anyhow!("Failed to open archive {}: {}", archive_path.display(), e))?; + let tar = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(tar); + tokio::task::spawn_blocking({ + let extracted_dir = extracted_dir.clone(); + move || archive.unpack(&extracted_dir) + .map_err(|e| anyhow::anyhow!("Failed to unpack archive: {}", e)) + }) + .await? + .map_err(|e| Error::Extract { + tool: self.tool_name().to_string(), + source: e, + })?; + debug!("tar.gz extraction complete"); + } + + // Create cache directory first before copying + let cache_dir = cache_entry.dir(); + debug!("Creating cache directory: {}", cache_dir.display()); + tokio::fs::create_dir_all(cache_dir).await?; + debug!("Cache directory created"); + + // Find the ruff binary in the extracted files + // The archive contains a directory with the platform name + let binary_name = self.binary_name(os); + let archive_dir_name = format!("ruff-{}", platform_name); + let extracted_binary = extracted_dir.join(&archive_dir_name).join(binary_name); + debug!("Looking for binary at: {}", extracted_binary.display()); + + if !extracted_binary.exists() { + debug!("Binary not found at expected location, trying direct path"); + // Try without the directory structure (in case archive format changes) + let direct_binary = extracted_dir.join(binary_name); + debug!("Checking direct binary path: {}", direct_binary.display()); + if direct_binary.exists() { + debug!("Found binary at direct path"); + // Copy binary to cache location + debug!("Copying binary from {} to {}", direct_binary.display(), cache_entry.path().display()); + tokio::fs::copy(&direct_binary, cache_entry.path()).await?; + debug!("Binary copied successfully"); + } else { + return Err(Error::BinaryNotFound { + tool: self.tool_name().to_string(), + expected: extracted_binary, + }); + } + } else { + debug!("Found binary at expected location"); + // Copy binary to cache location + debug!("Copying binary from {} to {}", extracted_binary.display(), cache_entry.path().display()); + tokio::fs::copy(&extracted_binary, cache_entry.path()).await?; + debug!("Binary copied successfully"); + } + + + // Make executable on Unix + #[cfg(unix)] + { + debug!("Setting executable permissions on binary"); + use std::os::unix::fs::PermissionsExt; + let mut perms = tokio::fs::metadata(cache_entry.path()).await?.permissions(); + perms.set_mode(0o755); + tokio::fs::set_permissions(cache_entry.path(), perms).await?; + debug!("Executable permissions set"); + } + + debug!("Cached Ruff binary at {}", cache_entry.path().display()); + debug!("Binary exists: {}", cache_entry.path().exists()); + + Ok(cache_entry.into_path_buf()) + } +} \ No newline at end of file diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 78d79ea778618..ede6f52207d11 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 } diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index 53348b038ddaf..cb6cee1971a7d 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -9,9 +9,8 @@ use tracing::debug; use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::BaseClientBuilder; -use uv_python::platform::{Arch, Libc, Os}; - -use crate::commands::project::ruff_download::RuffDownload; +use uv_bin_install::{BinaryDownloader, RuffDownloader}; +use uv_platform::{Arch, Libc, Os}; use crate::commands::ExitStatus; use crate::printer::Printer; use crate::settings::NetworkSettings; @@ -42,13 +41,7 @@ pub(crate) async fn format( debug!("Getting platform information"); let os = Os::from_env(); let arch = Arch::from_env(); - let libc = if cfg!(target_env = "musl") { - Libc::Some(target_lexicon::Environment::Musl) - } else if cfg!(target_os = "linux") { - Libc::Some(target_lexicon::Environment::Gnu) - } else { - Libc::None - }; + let libc = Libc::from_env()?; debug!("Platform: os={}, arch={}, libc={}", os, arch, libc); // Create HTTP client @@ -62,8 +55,9 @@ pub(crate) async fn format( debug!("HTTP client created"); // Download or retrieve Ruff binary from cache - debug!("Calling RuffDownload::download"); - let ruff_path = RuffDownload::download( + debug!("Calling RuffDownloader::download"); + let downloader = RuffDownloader; + let ruff_path = downloader.download( version.as_deref(), &os, &arch, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index a82807c962cba..8e91871e56f4a 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -68,7 +68,6 @@ mod install_target; pub(crate) mod lock; mod lock_target; pub(crate) mod remove; -pub(crate) mod ruff_download; pub(crate) mod run; pub(crate) mod sync; pub(crate) mod tree; From 807e6042b212ef4674828e5b4dacb368c4168b0f Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 14:34:00 -0500 Subject: [PATCH 04/19] Refactor bin installation --- Cargo.lock | 8 +- crates/uv-bin-install/Cargo.toml | 8 +- crates/uv-bin-install/src/download.rs | 46 ---- crates/uv-bin-install/src/error.rs | 57 ---- crates/uv-bin-install/src/lib.rs | 301 ++++++++++++++++++++- crates/uv-bin-install/src/ruff.rs | 324 ----------------------- crates/uv-python/src/downloads.rs | 1 + crates/uv-python/src/installation.rs | 1 + crates/uv-python/src/interpreter.rs | 1 + crates/uv-python/src/managed.rs | 2 + crates/uv/src/commands/project/format.rs | 57 ++-- crates/uv/src/settings.rs | 4 +- crates/uv/tests/it/format.rs | 20 +- 13 files changed, 339 insertions(+), 491 deletions(-) delete mode 100644 crates/uv-bin-install/src/download.rs delete mode 100644 crates/uv-bin-install/src/error.rs delete mode 100644 crates/uv-bin-install/src/ruff.rs diff --git a/Cargo.lock b/Cargo.lock index 89febfaefffd2..4caaeced5154c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4827,20 +4827,22 @@ name = "uv-bin-install" version = "0.0.1" dependencies = [ "anyhow", - "async-trait", - "flate2", "futures", "reqwest", "reqwest-middleware", - "tar", + "target-lexicon", "tempfile", "thiserror 2.0.12", "tokio", + "tokio-util", "tracing", "url", "uv-cache", "uv-client", + "uv-distribution-filename", "uv-extract", + "uv-fs", + "uv-pep440", "uv-platform", ] diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml index d2b079e5ad464..dd0ba55f841c5 100644 --- a/crates/uv-bin-install/Cargo.toml +++ b/crates/uv-bin-install/Cargo.toml @@ -19,18 +19,20 @@ workspace = true [dependencies] uv-cache = { workspace = true } uv-client = { workspace = true } +uv-distribution-filename = { workspace = true } uv-extract = { workspace = true } +uv-fs = { workspace = true } +uv-pep440 = { workspace = true } uv-platform = { workspace = true } anyhow = { workspace = true } -async-trait = { workspace = true } -flate2 = { workspace = true } futures = { workspace = true } reqwest = { workspace = true } reqwest-middleware = { workspace = true } -tar = { workspace = true } +target-lexicon = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +tokio-util = { workspace = true } tracing = { workspace = true } url = { workspace = true } \ No newline at end of file diff --git a/crates/uv-bin-install/src/download.rs b/crates/uv-bin-install/src/download.rs deleted file mode 100644 index 8720f165c3883..0000000000000 --- a/crates/uv-bin-install/src/download.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::path::PathBuf; - -use async_trait::async_trait; - -use uv_cache::Cache; -use uv_client::BaseClient; -use uv_platform::{Arch, Libc, Os}; - -use crate::Result; - -/// Trait for downloading and caching binary tools. -#[async_trait] -pub trait BinaryDownloader: Send + Sync { - /// The name of the tool being downloaded. - fn tool_name(&self) -> &str; - - /// The default version to use when none is specified. - fn default_version(&self) -> &str; - - /// Map platform information to a tool-specific platform identifier. - fn platform_identifier(&self, os: &Os, arch: &Arch, libc: &Libc) -> Option; - - /// Get the download URL for a specific version and platform. - fn download_url(&self, version: &str, platform: &str) -> String; - - /// Get the archive extension for the platform. - fn archive_extension(&self, os: &Os) -> &str; - - /// Get the expected binary name within the archive. - fn binary_name(&self, os: &Os) -> &str; - - /// Get the expected directory structure within the archive. - /// Returns None if the binary is at the root of the archive. - fn archive_directory(&self, platform: &str) -> Option; - - /// Download the binary for the specified version and platform. - async fn download( - &self, - version: Option<&str>, - os: &Os, - arch: &Arch, - libc: &Libc, - client: &BaseClient, - cache: &Cache, - ) -> Result; -} \ No newline at end of file diff --git a/crates/uv-bin-install/src/error.rs b/crates/uv-bin-install/src/error.rs deleted file mode 100644 index 2c945ee75efb9..0000000000000 --- a/crates/uv-bin-install/src/error.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::path::PathBuf; - -use thiserror::Error; - -/// Errors that can occur during binary download and installation. -#[derive(Debug, Error)] -pub enum Error { - /// Failed to download binary. - #[error("Failed to download {tool} {version} from {url}")] - Download { - tool: String, - version: String, - url: String, - #[source] - source: reqwest_middleware::Error, - }, - - /// Failed to parse download URL. - #[error("Failed to parse download URL: {url}")] - UrlParse { - url: String, - #[source] - source: url::ParseError, - }, - - /// Unsupported platform for binary download. - #[error("Unsupported platform for {tool}: {platform}")] - UnsupportedPlatform { tool: String, platform: String }, - - /// Failed to extract archive. - #[error("Failed to extract {tool} archive")] - Extract { - tool: String, - #[source] - source: anyhow::Error, - }, - - /// Binary not found in extracted archive. - #[error("Binary not found in {tool} archive at expected location: {expected}")] - BinaryNotFound { tool: String, expected: PathBuf }, - - /// Task join error. - #[error("Task join error")] - Join(#[from] tokio::task::JoinError), - - /// I/O error during installation. - #[error(transparent)] - Io(#[from] std::io::Error), - - /// Platform detection error. - #[error("Failed to detect platform")] - Platform(#[from] uv_platform::Error), - - /// Generic errors. - #[error(transparent)] - Anyhow(#[from] anyhow::Error), -} \ No newline at end of file diff --git a/crates/uv-bin-install/src/lib.rs b/crates/uv-bin-install/src/lib.rs index 8e44d29aee78e..5344078120793 100644 --- a/crates/uv-bin-install/src/lib.rs +++ b/crates/uv-bin-install/src/lib.rs @@ -3,13 +3,300 @@ //! This crate provides functionality for downloading and caching binary tools //! from various sources (GitHub releases, etc.) for use by uv. -pub use download::BinaryDownloader; -pub use error::Error; -pub use ruff::RuffDownloader; +use std::path::PathBuf; +use std::str::FromStr; -pub mod download; -pub mod error; -pub mod ruff; +use futures::TryStreamExt; +use thiserror::Error; +use tokio_util::compat::FuturesAsyncReadCompatExt; +use url::Url; + +use uv_cache::{Cache, CacheBucket, CacheEntry}; +use uv_client::BaseClient; +use uv_distribution_filename::SourceDistExtension; +use uv_extract::stream; +use uv_pep440::Version; +use uv_platform::{Arch, Libc, Os}; /// Result type for binary installation operations. -pub type Result = std::result::Result; \ No newline at end of file +pub type Result = std::result::Result; + +/// Binary tools that can be installed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Binary { + /// Ruff formatter and linter + Ruff, +} + +impl Binary { + /// Get the default version for this binary. + pub fn default_version(&self) -> Version { + match self { + Binary::Ruff => Version::from_str("0.12.5").expect("valid version"), + } + } + + /// Get the tool name for cache and display purposes. + pub fn name(&self) -> &'static str { + match self { + Binary::Ruff => "ruff", + } + } + + /// Get the download URL for a specific version and platform. + pub fn download_url(&self, version: &Version, platform: &str, os: &Os) -> Url { + match self { + Binary::Ruff => { + let archive_ext = if os.is_windows() { ".zip" } else { ".tar.gz" }; + let url_string = format!( + "https://github.com/astral-sh/ruff/releases/download/{version}/ruff-{platform}{archive_ext}" + ); + Url::parse(&url_string).expect("valid URL") + } + } + } + + /// Get the binary name for the target platform. + pub fn binary_name(&self, os: &Os) -> String { + let base_name = match self { + Binary::Ruff => "ruff", + }; + + if os.is_windows() { + format!("{}{}", base_name, std::env::consts::EXE_SUFFIX) + } else { + base_name.to_string() + } + } + + /// Get the expected directory name inside the archive. + pub fn archive_dir_name(&self, platform: &str) -> String { + match self { + Binary::Ruff => format!("ruff-{platform}"), + } + } +} + +/// Errors that can occur during binary download and installation. +#[derive(Debug, Error)] +pub enum Error { + /// Failed to download binary. + #[error("Failed to download {tool} {version} from {url}")] + Download { + tool: String, + version: String, + url: String, + #[source] + source: reqwest_middleware::Error, + }, + + /// Failed to parse download URL. + #[error("Failed to parse download URL: {url}")] + UrlParse { + url: String, + #[source] + source: url::ParseError, + }, + + /// Unsupported platform for binary download. + #[error("Unsupported platform for {tool}: {platform}")] + UnsupportedPlatform { tool: String, platform: String }, + + /// Failed to extract archive. + #[error("Failed to extract {tool} archive")] + Extract { + tool: String, + #[source] + source: anyhow::Error, + }, + + /// Binary not found in extracted archive. + #[error("Binary not found in {tool} archive at expected location: {expected}")] + BinaryNotFound { tool: String, expected: PathBuf }, + + /// Task join error. + #[error("Task join error")] + Join(#[from] tokio::task::JoinError), + + /// I/O error during installation. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// Platform detection error. + #[error("Failed to detect platform")] + Platform(#[from] uv_platform::Error), + + /// Generic errors. + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} + +/// Install a binary tool, handling platform detection internally. +pub async fn install( + binary: Binary, + version: Option<&Version>, + client: &BaseClient, + cache: &Cache, +) -> Result { + // Platform detection happens inside + let os = Os::from_env(); + let arch = Arch::from_env(); + let libc = Libc::from_env()?; + + // Get version to download + let version = version.cloned().unwrap_or_else(|| binary.default_version()); + + // Get platform-specific binary name + let platform_name = get_platform_name(os, arch, libc); + + // Check cache first + let cache_entry = CacheEntry::new( + cache + .bucket(CacheBucket::ToolBinaries) + .join(binary.name()) + .join(version.to_string()) + .join(&platform_name), + binary.binary_name(&os), + ); + + if cache_entry.path().exists() { + return Ok(cache_entry.into_path_buf()); + } + + // Get download URL + let download_url = binary.download_url(&version, &platform_name, &os); + + // Create cache directory first + let cache_dir = cache_entry.dir(); + tokio::fs::create_dir_all(&cache_dir).await?; + + // Create a temporary directory for extraction + let temp_dir = tempfile::tempdir_in(cache_dir.parent().unwrap()) + .map_err(|e| anyhow::anyhow!("Failed to create temp dir: {}", e))?; + + // Download and extract in one step + let response = client + .for_host(&download_url.clone().into()) + .get(download_url.clone()) + .send() + .await + .map_err(|e| Error::Download { + tool: binary.name().to_string(), + version: version.to_string(), + url: download_url.to_string(), + source: e, + })?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to download {}: {} returned {}", + binary.name(), + download_url, + response.status() + ) + .into()); + } + + // Determine archive type from URL + let ext = if os.is_windows() { + SourceDistExtension::Zip + } else { + SourceDistExtension::TarGz + }; + + // Stream download directly to extraction + let mut reader = response + .bytes_stream() + .map_err(std::io::Error::other) + .into_async_read() + .compat(); + + stream::archive(&mut reader, ext, temp_dir.path()) + .await + .map_err(|e| Error::Extract { + tool: binary.name().to_string(), + source: e.into(), + })?; + + // Find the binary in the extracted files + // The archive contains a directory with the platform name + let binary_name = binary.binary_name(&os); + let archive_dir_name = binary.archive_dir_name(&platform_name); + let extracted_binary = temp_dir.path().join(&archive_dir_name).join(&binary_name); + + if !extracted_binary.exists() { + // Try without the directory structure (in case archive format changes) + let direct_binary = temp_dir.path().join(&binary_name); + if direct_binary.exists() { + // Copy binary to cache location + tokio::fs::copy(&direct_binary, cache_entry.path()).await?; + } else { + return Err(Error::BinaryNotFound { + tool: binary.name().to_string(), + expected: extracted_binary, + }); + } + } else { + // Copy binary to cache location + tokio::fs::copy(&extracted_binary, cache_entry.path()).await?; + } + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = tokio::fs::metadata(cache_entry.path()).await?.permissions(); + perms.set_mode(0o755); + tokio::fs::set_permissions(cache_entry.path(), perms).await?; + } + + Ok(cache_entry.into_path_buf()) +} + +/// Map UV's platform types to standard target triple naming convention. +fn get_platform_name(os: Os, arch: Arch, libc: Libc) -> String { + use target_lexicon::{ + Architecture, ArmArchitecture, OperatingSystem, Riscv64Architecture, X86_32Architecture, + }; + + // Get base architecture string + let arch_str = 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(), + }; + + // Determine vendor + let vendor = match &*os { + OperatingSystem::Darwin(_) => "apple", + OperatingSystem::Windows => "pc", + _ => "unknown", + }; + + // Map OS names (only Darwin needs special handling) + let os_name = match &*os { + OperatingSystem::Darwin(_) => "darwin", + _ => &os.to_string(), + }; + + // Build base triple + let mut triple = format!("{arch_str}-{vendor}-{os_name}"); + + // Add environment/ABI suffix + match (&*os, libc) { + (OperatingSystem::Windows, _) => triple.push_str("-msvc"), + (OperatingSystem::Linux, Libc::Some(env)) => { + triple.push('-'); + triple.push_str(&env.to_string()); + + // Special suffix for ARM with hardware float + if matches!(arch.family(), Architecture::Arm(ArmArchitecture::Armv7)) { + triple.push_str("eabihf"); + } + } + _ => {} + } + + triple +} diff --git a/crates/uv-bin-install/src/ruff.rs b/crates/uv-bin-install/src/ruff.rs deleted file mode 100644 index de44c4ace3d93..0000000000000 --- a/crates/uv-bin-install/src/ruff.rs +++ /dev/null @@ -1,324 +0,0 @@ -use std::path::PathBuf; -use std::str::FromStr; - -use async_trait::async_trait; -use futures::TryStreamExt; -use tokio::io::AsyncWriteExt; -use tracing::debug; -use url::Url; - -use uv_cache::{Cache, CacheBucket, CacheEntry}; -use uv_client::BaseClient; -use uv_extract::unzip; -use uv_platform::{Arch, Libc, Os}; - -use crate::download::BinaryDownloader; -use crate::error::Error; - -/// Download and cache Ruff binaries. -pub struct RuffDownloader; - -impl RuffDownloader { - /// Default Ruff version to use when none is specified. - /// This should be updated when UV is released to ensure compatibility. - const DEFAULT_VERSION: &'static str = "0.12.5"; - - /// Map UV's platform types to Ruff's binary naming convention. - fn platform_to_ruff_name(os: &Os, arch: &Arch, libc: &Libc) -> Option<&'static str> { - // Convert to string representations for matching - // This is a workaround until we have a dedicated uv-platform crate - let os_str = os.to_string(); - let arch_str = arch.to_string(); - let libc_str = libc.to_string(); - - match (os_str.as_str(), arch_str.as_str(), libc_str.as_str()) { - // macOS - ("macos", "x86_64", _) => Some("x86_64-apple-darwin"), - ("macos", "aarch64", _) => Some("aarch64-apple-darwin"), - - // Windows - ("windows", "x86_64", _) => Some("x86_64-pc-windows-msvc"), - ("windows", "aarch64", _) => Some("aarch64-pc-windows-msvc"), - ("windows", "x86", _) => Some("i686-pc-windows-msvc"), - - // Linux with glibc - ("linux", "x86_64", "gnu") => Some("x86_64-unknown-linux-gnu"), - ("linux", "aarch64", "gnu") => Some("aarch64-unknown-linux-gnu"), - ("linux", "x86", "gnu") => Some("i686-unknown-linux-gnu"), - ("linux", "arm", "gnu") => Some("armv7-unknown-linux-gnueabihf"), - ("linux", "s390x", "gnu") => Some("s390x-unknown-linux-gnu"), - ("linux", "powerpc64", "gnu") => Some("powerpc64-unknown-linux-gnu"), - ("linux", "powerpc64le", "gnu") => Some("powerpc64le-unknown-linux-gnu"), - ("linux", "riscv64", "gnu") => Some("riscv64gc-unknown-linux-gnu"), - - // Linux with musl - ("linux", "x86_64", "musl") => Some("x86_64-unknown-linux-musl"), - ("linux", "aarch64", "musl") => Some("aarch64-unknown-linux-musl"), - ("linux", "x86", "musl") => Some("i686-unknown-linux-musl"), - ("linux", "arm", "musl") => Some("armv7-unknown-linux-musleabihf"), - - _ => None, - } - } - - /// Get the file extension for the platform. - fn get_archive_extension(os: &Os) -> &'static str { - match &**os { - // Check if it's Windows by looking at the Display output - os if os.to_string().contains("windows") => ".zip", - _ => ".tar.gz", - } - } -} - -#[async_trait] -impl BinaryDownloader for RuffDownloader { - fn tool_name(&self) -> &str { - "ruff" - } - - fn default_version(&self) -> &str { - Self::DEFAULT_VERSION - } - - fn platform_identifier(&self, os: &Os, arch: &Arch, libc: &Libc) -> Option { - Self::platform_to_ruff_name(os, arch, libc).map(String::from) - } - - fn download_url(&self, version: &str, platform: &str) -> String { - let archive_extension = if platform.contains("windows") { - ".zip" - } else { - ".tar.gz" - }; - let archive_name = format!("ruff-{}{}", platform, archive_extension); - format!( - "https://github.com/astral-sh/ruff/releases/download/{}/{}", - version, archive_name - ) - } - - fn archive_extension(&self, os: &Os) -> &str { - Self::get_archive_extension(os) - } - - fn binary_name(&self, os: &Os) -> &str { - if os.to_string().contains("windows") { - "ruff.exe" - } else { - "ruff" - } - } - - fn archive_directory(&self, platform: &str) -> Option { - Some(format!("ruff-{}", platform)) - } - - async fn download( - &self, - version: Option<&str>, - os: &Os, - arch: &Arch, - libc: &Libc, - client: &BaseClient, - cache: &Cache, - ) -> crate::Result { - debug!("RuffDownloader::download called with version: {:?}", version); - // Get version to download - let version = if let Some(v) = version { - v.to_string() - } else { - self.default_version().to_string() - }; - - // Get platform-specific binary name - debug!("Getting platform name for os={}, arch={}, libc={}", os, arch, libc); - let platform_name = self.platform_identifier(os, arch, libc) - .ok_or_else(|| Error::UnsupportedPlatform { - tool: self.tool_name().to_string(), - platform: format!("{:?}-{:?}", os, arch), - })?; - debug!("Platform name: {}", platform_name); - - let archive_extension = self.archive_extension(os); - let archive_name = format!("ruff-{}{}", platform_name, archive_extension); - debug!("Archive name: {}", archive_name); - - // Check cache first - debug!("Creating cache entry"); - let cache_entry = CacheEntry::new( - cache.bucket(CacheBucket::ToolBinaries).join(self.tool_name()).join(&version).join(&platform_name), - self.binary_name(os), - ); - debug!("Cache entry created at: {}", cache_entry.path().display()); - - if cache_entry.path().exists() { - debug!("Using cached Ruff binary at {}", cache_entry.path().display()); - return Ok(cache_entry.into_path_buf()); - } - - debug!("Cache entry path: {}", cache_entry.path().display()); - debug!("Cache dir: {}", cache_entry.dir().display()); - - // Download URL - let download_url = self.download_url(&version, &platform_name); - - debug!("Downloading Ruff {} from {}", version, download_url); - - // Create cache directory first - let cache_bucket_dir = cache.bucket(CacheBucket::ToolBinaries); - debug!("Creating cache bucket dir: {}", cache_bucket_dir.display()); - tokio::fs::create_dir_all(&cache_bucket_dir).await - .map_err(|e| anyhow::anyhow!("Failed to create cache bucket dir {}: {}", cache_bucket_dir.display(), e))?; - debug!("Cache bucket dir created successfully"); - - // Download to temporary file - debug!("Creating temp dir in: {}", cache_bucket_dir.display()); - let temp_dir = tempfile::tempdir_in(&cache_bucket_dir) - .map_err(|e| anyhow::anyhow!("Failed to create temp dir in {}: {}", cache_bucket_dir.display(), e))?; - debug!("Temp dir created at: {}", temp_dir.path().display()); - let archive_path = temp_dir.path().join(&archive_name); - debug!("Archive will be downloaded to: {}", archive_path.display()); - - debug!("Sending HTTP request to download URL"); - let response = client - .for_host(&Url::parse(&download_url).map_err(|e| Error::UrlParse { - url: download_url.clone(), - source: e, - })?.into()) - .get(reqwest::Url::from_str(&download_url).map_err(|e| Error::UrlParse { - url: download_url.clone(), - source: e, - })?) - .send() - .await - .map_err(|e| Error::Download { - tool: self.tool_name().to_string(), - version: version.clone(), - url: download_url.clone(), - source: e, - })?; - debug!("HTTP response received: status={}", response.status()); - - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Failed to download Ruff: {} returned {}", - download_url, - response.status() - ).into()); - } - - // Write to file - debug!("Creating archive file at: {}", archive_path.display()); - let mut file = tokio::fs::File::create(&archive_path).await - .map_err(|e| anyhow::anyhow!("Failed to create archive file {}: {}", archive_path.display(), e))?; - debug!("Archive file created, starting download"); - let stream = response - .bytes_stream() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)); - - futures::pin_mut!(stream); - while let Some(chunk) = stream.try_next().await? { - file.write_all(&chunk).await?; - } - file.flush().await?; - drop(file); - debug!("Download complete, file written to: {}", archive_path.display()); - - // Extract archive - let extracted_dir = temp_dir.path().join("extracted"); - debug!("Creating extraction directory: {}", extracted_dir.display()); - tokio::fs::create_dir_all(&extracted_dir).await - .map_err(|e| anyhow::anyhow!("Failed to create extracted dir {}: {}", extracted_dir.display(), e))?; - debug!("Extraction directory created"); - - // Extract based on file extension - debug!("Starting extraction for archive type: {}", archive_extension); - if archive_name.ends_with(".zip") { - let file = std::fs::File::open(&archive_path)?; - tokio::task::spawn_blocking({ - let extracted_dir = extracted_dir.clone(); - move || unzip(file, &extracted_dir) - }) - .await? - .map_err(|e| Error::Extract { - tool: self.tool_name().to_string(), - source: e.into(), - })?; - debug!("ZIP extraction complete"); - } else { - debug!("Extracting tar.gz archive"); - // For .tar.gz files, we need to extract manually - let file = std::fs::File::open(&archive_path) - .map_err(|e| anyhow::anyhow!("Failed to open archive {}: {}", archive_path.display(), e))?; - let tar = flate2::read::GzDecoder::new(file); - let mut archive = tar::Archive::new(tar); - tokio::task::spawn_blocking({ - let extracted_dir = extracted_dir.clone(); - move || archive.unpack(&extracted_dir) - .map_err(|e| anyhow::anyhow!("Failed to unpack archive: {}", e)) - }) - .await? - .map_err(|e| Error::Extract { - tool: self.tool_name().to_string(), - source: e, - })?; - debug!("tar.gz extraction complete"); - } - - // Create cache directory first before copying - let cache_dir = cache_entry.dir(); - debug!("Creating cache directory: {}", cache_dir.display()); - tokio::fs::create_dir_all(cache_dir).await?; - debug!("Cache directory created"); - - // Find the ruff binary in the extracted files - // The archive contains a directory with the platform name - let binary_name = self.binary_name(os); - let archive_dir_name = format!("ruff-{}", platform_name); - let extracted_binary = extracted_dir.join(&archive_dir_name).join(binary_name); - debug!("Looking for binary at: {}", extracted_binary.display()); - - if !extracted_binary.exists() { - debug!("Binary not found at expected location, trying direct path"); - // Try without the directory structure (in case archive format changes) - let direct_binary = extracted_dir.join(binary_name); - debug!("Checking direct binary path: {}", direct_binary.display()); - if direct_binary.exists() { - debug!("Found binary at direct path"); - // Copy binary to cache location - debug!("Copying binary from {} to {}", direct_binary.display(), cache_entry.path().display()); - tokio::fs::copy(&direct_binary, cache_entry.path()).await?; - debug!("Binary copied successfully"); - } else { - return Err(Error::BinaryNotFound { - tool: self.tool_name().to_string(), - expected: extracted_binary, - }); - } - } else { - debug!("Found binary at expected location"); - // Copy binary to cache location - debug!("Copying binary from {} to {}", extracted_binary.display(), cache_entry.path().display()); - tokio::fs::copy(&extracted_binary, cache_entry.path()).await?; - debug!("Binary copied successfully"); - } - - - // Make executable on Unix - #[cfg(unix)] - { - debug!("Setting executable permissions on binary"); - use std::os::unix::fs::PermissionsExt; - let mut perms = tokio::fs::metadata(cache_entry.path()).await?.permissions(); - perms.set_mode(0o755); - tokio::fs::set_permissions(cache_entry.path(), perms).await?; - debug!("Executable permissions set"); - } - - debug!("Cached Ruff binary at {}", cache_entry.path().display()); - debug!("Binary exists: {}", cache_entry.path().exists()); - - Ok(cache_entry.into_path_buf()) - } -} \ No newline at end of file diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 9e1e03c912a7e..0e53432d9968e 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -37,6 +37,7 @@ use crate::implementation::{ use crate::installation::PythonInstallationKey; use crate::managed::ManagedPythonInstallation; use crate::{Interpreter, PythonRequest, PythonVersion, VersionRequest}; +use uv_platform::{self as platform, Arch, Libc, Os}; #[derive(Error, Debug)] pub enum Error { diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 8cdc33106c488..0875f212cdd61 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -22,6 +22,7 @@ use crate::{ Error, ImplementationName, Interpreter, PythonDownloads, PythonPreference, PythonSource, PythonVariant, PythonVersion, downloads, }; +use uv_platform::{Arch, Libc, Os}; /// A Python interpreter and accompanying tools. #[derive(Clone, Debug)] diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 3a7cce3f08e1b..53a3c1e4db772 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -33,6 +33,7 @@ use crate::{ Prefix, PythonInstallationKey, PythonVariant, PythonVersion, Target, VersionRequest, VirtualEnvironment, }; +use uv_platform::{Arch, Libc, Os}; #[cfg(windows)] use windows_sys::Win32::Foundation::{APPMODEL_ERROR_NO_PACKAGE, ERROR_CANT_ACCESS_FILE}; diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 69d12a0a37553..0a30cc4b2c854 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -32,6 +32,8 @@ use crate::python_version::PythonVersion; use crate::{ PythonInstallationMinorVersionKey, PythonRequest, PythonVariant, macos_dylib, sysconfig, }; +use uv_platform::Error as PlatformError; +use uv_platform::{Arch, Libc, LibcDetectionError, Os}; #[derive(Error, Debug)] pub enum Error { diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index cb6cee1971a7d..6a517b161a9a2 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -1,16 +1,17 @@ use std::fmt::Write; use std::io::Write as IoWrite; use std::path::PathBuf; +use std::str::FromStr; use anyhow::{Context, Result}; use tokio::process::Command; -use tracing::debug; +use uv_bin_install::{Binary, install}; use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::BaseClientBuilder; -use uv_bin_install::{BinaryDownloader, RuffDownloader}; -use uv_platform::{Arch, Libc, Os}; +use uv_pep440::Version; + use crate::commands::ExitStatus; use crate::printer::Printer; use crate::settings::NetworkSettings; @@ -26,7 +27,6 @@ pub(crate) async fn format( cache: Cache, printer: Printer, ) -> Result { - debug!("format command called with check={}, diff={}, files={:?}, version={:?}", check, diff, files, version); // Check if we're in offline mode if network_settings.connectivity.is_offline() && version.is_none() { // In offline mode without a specific version, we can't determine the latest version @@ -37,40 +37,27 @@ pub(crate) async fn format( return Ok(ExitStatus::Failure); } - // Get current platform information - debug!("Getting platform information"); - let os = Os::from_env(); - let arch = Arch::from_env(); - let libc = Libc::from_env()?; - debug!("Platform: os={}, arch={}, libc={}", os, arch, libc); + // Parse version if provided + let version = version + .as_deref() + .map(Version::from_str) + .transpose() + .context("Invalid version format")?; // Create HTTP client - debug!("Creating HTTP client"); 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(); - debug!("HTTP client created"); // Download or retrieve Ruff binary from cache - debug!("Calling RuffDownloader::download"); - let downloader = RuffDownloader; - let ruff_path = downloader.download( - version.as_deref(), - &os, - &arch, - &libc, - &client, - &cache, - ) - .await - .context("Failed to download Ruff")?; - debug!("Got ruff binary at: {}", ruff_path.display()); + let ruff_path = install(Binary::Ruff, version.as_ref(), &client, &cache) + .await + .context("Failed to install Ruff")?; // Construct the ruff format command. - debug!("Constructing ruff command with binary: {}", ruff_path.display()); let mut command = Command::new(&ruff_path); command.arg("format"); @@ -101,17 +88,11 @@ pub(crate) async fn format( } } - debug!("Full ruff format command: {:?}", command); - debug!("About to execute command"); - // Run the ruff format command. - debug!("Executing command.output()"); - let output = command.output().await - .map_err(|e| { - debug!("Command execution failed: {}", e); - anyhow::anyhow!("Failed to run ruff format at {}: {}", ruff_path.display(), e) - })?; - debug!("Command executed successfully, status: {}", output.status); + let output = command + .output() + .await + .context("Failed to run ruff format")?; // Stream stdout and stderr. if !output.stdout.is_empty() { @@ -123,10 +104,8 @@ pub(crate) async fn format( // Return the exit status. if output.status.success() { - debug!("Ruff format completed successfully"); Ok(ExitStatus::Success) } else { - debug!("Ruff format failed with non-zero exit code"); Ok(ExitStatus::Failure) } -} \ No newline at end of file +} diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 5a64993ac9e3c..e97823bdfff6b 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, FormatArgs, PublishArgs, PythonDirArgs, ResolverInstallerArgs, - ToolUpgradeArgs, + AuthorFrom, BuildArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs, + ResolverInstallerArgs, ToolUpgradeArgs, options::{flag, resolver_installer_options, resolver_options}, }; use uv_client::Connectivity; diff --git a/crates/uv/tests/it/format.rs b/crates/uv/tests/it/format.rs index 7e60e8ad1b66d..2f898b8d0e3c7 100644 --- a/crates/uv/tests/it/format.rs +++ b/crates/uv/tests/it/format.rs @@ -215,10 +215,10 @@ fn format_multiple_files() -> Result<()> { "#})?; let utils_py = context.temp_dir.child("utils.py"); - utils_py.write_str(indoc! {r#" + utils_py.write_str(indoc! {r" def util(): return 42 - "#})?; + "})?; // Run format on both files uv_snapshot!(context.filters(), context.format().arg("main.py").arg("utils.py"), @r" @@ -264,10 +264,10 @@ fn format_directory() -> Result<()> { src_dir.create_dir_all()?; let module_py = src_dir.child("module.py"); - module_py.write_str(indoc! {r#" + module_py.write_str(indoc! {r" def func(): pass - "#})?; + "})?; // Run format on directory uv_snapshot!(context.filters(), context.format().arg("src/"), @r" @@ -344,9 +344,9 @@ fn format_cache_reuse() -> Result<()> { // Create Python file let main_py = context.temp_dir.child("main.py"); - main_py.write_str(indoc! {r#" + main_py.write_str(indoc! {r" def hello(): pass - "#})?; + "})?; // First run - installs Ruff uv_snapshot!(context.filters(), context.format().arg("main.py"), @r" @@ -359,9 +359,9 @@ fn format_cache_reuse() -> Result<()> { "); // Modify the file again - main_py.write_str(indoc! {r#" + main_py.write_str(indoc! {r" def goodbye(): pass - "#})?; + "})?; // Second run - should reuse cached Ruff uv_snapshot!(context.filters(), context.format().arg("main.py"), @r" @@ -390,9 +390,9 @@ fn format_version_option() -> Result<()> { "#})?; let main_py = context.temp_dir.child("main.py"); - main_py.write_str(indoc! {r#" + main_py.write_str(indoc! {r" def hello(): pass - "#})?; + "})?; // Run format with specific Ruff version uv_snapshot!(context.filters(), context.format().arg("--version").arg("0.8.2").arg("main.py"), @r" From 6aec1f9edc28f8dbf3ce58fbfa4f79b5b10befa5 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 12:56:12 -0500 Subject: [PATCH 05/19] Drop `files` option, use `fs_err` in tests --- crates/uv-bin-install/Cargo.toml | 3 +- crates/uv-cli/src/lib.rs | 9 +---- crates/uv/src/commands/project/format.rs | 14 ++----- crates/uv/src/lib.rs | 1 - crates/uv/src/settings.rs | 3 -- crates/uv/tests/it/format.rs | 48 ++++++++++++------------ 6 files changed, 31 insertions(+), 47 deletions(-) diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml index dd0ba55f841c5..0a59be46e5472 100644 --- a/crates/uv-bin-install/Cargo.toml +++ b/crates/uv-bin-install/Cargo.toml @@ -35,4 +35,5 @@ thiserror = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } tracing = { workspace = true } -url = { workspace = true } \ No newline at end of file +url = { workspace = true } + diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 0176745362ad4..c7d4894e76b8e 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4279,11 +4279,6 @@ pub struct FormatArgs { #[arg(long)] pub diff: bool, - /// Files or directories to format. - /// - /// If no files are specified, the current directory is formatted. - #[arg(value_name = "FILES")] - pub files: Vec, /// The version of Ruff to use for formatting. /// @@ -4291,10 +4286,10 @@ pub struct FormatArgs { #[arg(long)] pub version: Option, - /// Additional arguments to pass to Ruff. + /// Additional arguments to pass to Ruff, including specific files or directories. /// /// Use `--` to separate these arguments from uv arguments. - /// For example: `uv format src/ -- --line-length 100` + /// For example: `uv format -- --line-length 100` or `uv format -- src/` #[command(subcommand)] pub args: Option, } diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index 6a517b161a9a2..0a9ca82e8aa88 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -1,6 +1,5 @@ use std::fmt::Write; use std::io::Write as IoWrite; -use std::path::PathBuf; use std::str::FromStr; use anyhow::{Context, Result}; @@ -20,7 +19,6 @@ use crate::settings::NetworkSettings; pub(crate) async fn format( check: bool, diff: bool, - files: Vec, args: Option, version: Option, network_settings: NetworkSettings, @@ -71,15 +69,9 @@ pub(crate) async fn format( command.arg("--diff"); } - // Add files or directories to format. - if files.is_empty() { - // If no files specified, format the current directory. - command.arg("."); - } else { - for file in &files { - command.arg(file); - } - } + // Ruff format defaults to the current directory when no files are specified. + // If the user wants to format specific files, they can pass them after -- + // e.g., uv format -- src/main.py // Add any additional arguments passed after --. if let Some(args) = args { diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 43bc06fc0c541..14bd2dbffa0eb 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2224,7 +2224,6 @@ async fn run_project( Box::pin(commands::format( args.check, args.diff, - args.files, args.args, args.version, globals.network_settings, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index e97823bdfff6b..acfada8988e64 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1867,7 +1867,6 @@ impl ExportSettings { pub(crate) struct FormatSettings { pub(crate) check: bool, pub(crate) diff: bool, - pub(crate) files: Vec, pub(crate) args: Option, pub(crate) version: Option, } @@ -1878,7 +1877,6 @@ impl FormatSettings { let FormatArgs { check, diff, - files, args, version, } = args; @@ -1886,7 +1884,6 @@ impl FormatSettings { Self { check, diff, - files, args, version, } diff --git a/crates/uv/tests/it/format.rs b/crates/uv/tests/it/format.rs index 2f898b8d0e3c7..cf0cda098b88c 100644 --- a/crates/uv/tests/it/format.rs +++ b/crates/uv/tests/it/format.rs @@ -29,7 +29,7 @@ fn format_project() -> Result<()> { "#})?; // Snapshot the original content - let original_content = std::fs::read_to_string(&main_py)?; + let original_content = fs_err::read_to_string(&main_py)?; assert_snapshot!(original_content, @r#" import sys def hello(): @@ -38,8 +38,8 @@ fn format_project() -> Result<()> { hello( ) "#); - // Run format - uv_snapshot!(context.filters(), context.format().arg("main.py"), @r" + // Run format (formats current directory by default) + uv_snapshot!(context.filters(), context.format(), @r" success: true exit_code: 0 ----- stdout ----- @@ -49,7 +49,7 @@ fn format_project() -> Result<()> { "); // Check that the file was formatted - let formatted_content = std::fs::read_to_string(&main_py)?; + let formatted_content = fs_err::read_to_string(&main_py)?; assert_snapshot!(formatted_content, @r#" import sys @@ -86,7 +86,7 @@ fn format_check() -> Result<()> { "#})?; // Run format with --check - uv_snapshot!(context.filters(), context.format().arg("--check").arg("main.py"), @r" + uv_snapshot!(context.filters(), context.format().arg("--check"), @r" success: false exit_code: 1 ----- stdout ----- @@ -97,7 +97,7 @@ fn format_check() -> Result<()> { "); // Verify the file wasn't modified - let content = std::fs::read_to_string(&main_py)?; + let content = fs_err::read_to_string(&main_py)?; assert_snapshot!(content, @r#" def hello(): print( "Hello, World!" ) @@ -127,7 +127,7 @@ fn format_diff() -> Result<()> { "#})?; // Run format with --diff - uv_snapshot!(context.filters(), context.format().arg("--diff").arg("main.py"), @r#" + uv_snapshot!(context.filters(), context.format().arg("--diff"), @r#" success: false exit_code: 1 ----- stdout ----- @@ -145,7 +145,7 @@ fn format_diff() -> Result<()> { "#); // Verify the file wasn't modified - let content = std::fs::read_to_string(&main_py)?; + let content = fs_err::read_to_string(&main_py)?; assert_snapshot!(content, @r#" def hello(): print( "Hello, World!" ) @@ -155,7 +155,7 @@ fn format_diff() -> Result<()> { } #[test] -fn format_with_args() -> Result<()> { +fn format_with_ruff_args() -> Result<()> { let context = TestContext::new("3.12"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -175,7 +175,7 @@ fn format_with_args() -> Result<()> { "#})?; // Run format with custom line length - uv_snapshot!(context.filters(), context.format().arg("main.py").arg("--").arg("--line-length").arg("200"), @r" + uv_snapshot!(context.filters(), context.format().arg("--").arg("main.py").arg("--line-length").arg("200"), @r" success: true exit_code: 0 ----- stdout ----- @@ -185,7 +185,7 @@ fn format_with_args() -> Result<()> { "); // Check that the line wasn't wrapped (because we set a high line length) - let formatted_content = std::fs::read_to_string(&main_py)?; + 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") @@ -195,7 +195,7 @@ fn format_with_args() -> Result<()> { } #[test] -fn format_multiple_files() -> Result<()> { +fn format_specific_files() -> Result<()> { let context = TestContext::new("3.12"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -220,8 +220,8 @@ fn format_multiple_files() -> Result<()> { return 42 "})?; - // Run format on both files - uv_snapshot!(context.filters(), context.format().arg("main.py").arg("utils.py"), @r" + // Run format on specific files using -- + uv_snapshot!(context.filters(), context.format().arg("--").arg("main.py").arg("utils.py"), @r" success: true exit_code: 0 ----- stdout ----- @@ -231,13 +231,13 @@ fn format_multiple_files() -> Result<()> { "); // Check that both files were formatted - let main_content = std::fs::read_to_string(&main_py)?; + let main_content = fs_err::read_to_string(&main_py)?; assert_snapshot!(main_content, @r#" def main(): print("Main") "#); - let utils_content = std::fs::read_to_string(&utils_py)?; + let utils_content = fs_err::read_to_string(&utils_py)?; assert_snapshot!(utils_content, @r#" def util(): return 42 @@ -247,7 +247,7 @@ fn format_multiple_files() -> Result<()> { } #[test] -fn format_directory() -> Result<()> { +fn format_specific_directory() -> Result<()> { let context = TestContext::new("3.12"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -269,8 +269,8 @@ fn format_directory() -> Result<()> { pass "})?; - // Run format on directory - uv_snapshot!(context.filters(), context.format().arg("src/"), @r" + // Run format on specific directory using -- + uv_snapshot!(context.filters(), context.format().arg("--").arg("src/"), @r" success: true exit_code: 0 ----- stdout ----- @@ -280,7 +280,7 @@ fn format_directory() -> Result<()> { "); // Check that the file in the directory was formatted - let module_content = std::fs::read_to_string(&module_py)?; + let module_content = fs_err::read_to_string(&module_py)?; assert_snapshot!(module_content, @r#" def func(): pass @@ -320,7 +320,7 @@ fn format_no_files() -> Result<()> { "); // Check that the file was formatted - let content = std::fs::read_to_string(&main_py)?; + let content = fs_err::read_to_string(&main_py)?; assert_snapshot!(content, @r#" def hello(): print("Hello") @@ -349,7 +349,7 @@ fn format_cache_reuse() -> Result<()> { "})?; // First run - installs Ruff - uv_snapshot!(context.filters(), context.format().arg("main.py"), @r" + uv_snapshot!(context.filters(), context.format(), @r" success: true exit_code: 0 ----- stdout ----- @@ -364,7 +364,7 @@ fn format_cache_reuse() -> Result<()> { "})?; // Second run - should reuse cached Ruff - uv_snapshot!(context.filters(), context.format().arg("main.py"), @r" + uv_snapshot!(context.filters(), context.format(), @r" success: true exit_code: 0 ----- stdout ----- @@ -395,7 +395,7 @@ fn format_version_option() -> Result<()> { "})?; // Run format with specific Ruff version - uv_snapshot!(context.filters(), context.format().arg("--version").arg("0.8.2").arg("main.py"), @r" + uv_snapshot!(context.filters(), context.format().arg("--version").arg("0.8.2"), @r" success: true exit_code: 0 ----- stdout ----- From 9605d25272fe43b4c854c3747fd4aee9c8775bdf Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 14:12:25 -0500 Subject: [PATCH 06/19] Self-review --- crates/uv-bin-install/Cargo.toml | 2 - crates/uv-bin-install/src/lib.rs | 185 ++++++++--------------- crates/uv-cli/src/lib.rs | 1 - crates/uv-python/src/downloads.rs | 1 - crates/uv-python/src/installation.rs | 1 - crates/uv-python/src/interpreter.rs | 1 - crates/uv-python/src/managed.rs | 2 - crates/uv/Cargo.toml | 1 - crates/uv/src/commands/project/format.rs | 62 ++------ 9 files changed, 73 insertions(+), 183 deletions(-) diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml index 0a59be46e5472..4976642527f75 100644 --- a/crates/uv-bin-install/Cargo.toml +++ b/crates/uv-bin-install/Cargo.toml @@ -27,13 +27,11 @@ uv-platform = { workspace = true } anyhow = { workspace = true } futures = { workspace = true } -reqwest = { workspace = true } reqwest-middleware = { workspace = true } target-lexicon = { 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 index 5344078120793..234cefdb0c31f 100644 --- a/crates/uv-bin-install/src/lib.rs +++ b/crates/uv-bin-install/src/lib.rs @@ -18,13 +18,9 @@ use uv_extract::stream; use uv_pep440::Version; use uv_platform::{Arch, Libc, Os}; -/// Result type for binary installation operations. -pub type Result = std::result::Result; - /// Binary tools that can be installed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Binary { - /// Ruff formatter and linter Ruff, } @@ -36,7 +32,9 @@ impl Binary { } } - /// Get the tool name for cache and display purposes. + /// The name of the binary. + /// + /// See [`Binary::executable`] for the platform-specific executable name. pub fn name(&self) -> &'static str { match self { Binary::Ruff => "ruff", @@ -44,36 +42,25 @@ impl Binary { } /// Get the download URL for a specific version and platform. - pub fn download_url(&self, version: &Version, platform: &str, os: &Os) -> Url { + pub fn download_url( + &self, + version: &Version, + platform: &str, + ext: &SourceDistExtension, + ) -> Result { match self { Binary::Ruff => { - let archive_ext = if os.is_windows() { ".zip" } else { ".tar.gz" }; - let url_string = format!( - "https://github.com/astral-sh/ruff/releases/download/{version}/ruff-{platform}{archive_ext}" + let url = format!( + "https://github.com/astral-sh/ruff/releases/download/{version}/ruff-{platform}.{ext}" ); - Url::parse(&url_string).expect("valid URL") + Url::parse(&url).map_err(|err| Error::UrlParse { url, source: err }) } } } - /// Get the binary name for the target platform. - pub fn binary_name(&self, os: &Os) -> String { - let base_name = match self { - Binary::Ruff => "ruff", - }; - - if os.is_windows() { - format!("{}{}", base_name, std::env::consts::EXE_SUFFIX) - } else { - base_name.to_string() - } - } - - /// Get the expected directory name inside the archive. - pub fn archive_dir_name(&self, platform: &str) -> String { - match self { - Binary::Ruff => format!("ruff-{platform}"), - } + /// Get the executable name + pub fn executable(&self) -> String { + format!("{}{}", self.name(), std::env::consts::EXE_SUFFIX) } } @@ -98,10 +85,6 @@ pub enum Error { source: url::ParseError, }, - /// Unsupported platform for binary download. - #[error("Unsupported platform for {tool}: {platform}")] - UnsupportedPlatform { tool: String, platform: String }, - /// Failed to extract archive. #[error("Failed to extract {tool} archive")] Extract { @@ -114,10 +97,6 @@ pub enum Error { #[error("Binary not found in {tool} archive at expected location: {expected}")] BinaryNotFound { tool: String, expected: PathBuf }, - /// Task join error. - #[error("Task join error")] - Join(#[from] tokio::task::JoinError), - /// I/O error during installation. #[error(transparent)] Io(#[from] std::io::Error), @@ -125,84 +104,67 @@ pub enum Error { /// Platform detection error. #[error("Failed to detect platform")] Platform(#[from] uv_platform::Error), - - /// Generic errors. - #[error(transparent)] - Anyhow(#[from] anyhow::Error), } -/// Install a binary tool, handling platform detection internally. -pub async fn install( +/// Install a binary for the given tool. +pub async fn bin_install( binary: Binary, version: Option<&Version>, client: &BaseClient, cache: &Cache, -) -> Result { - // Platform detection happens inside +) -> Result { let os = Os::from_env(); let arch = Arch::from_env(); let libc = Libc::from_env()?; - - // Get version to download let version = version.cloned().unwrap_or_else(|| binary.default_version()); + let platform_name = platform_name_for_binary(os, arch, libc); - // Get platform-specific binary name - let platform_name = get_platform_name(os, arch, libc); - - // Check cache first + // Check the cache first let cache_entry = CacheEntry::new( cache - .bucket(CacheBucket::ToolBinaries) + .bucket(CacheBucket::Binaries) .join(binary.name()) .join(version.to_string()) .join(&platform_name), - binary.binary_name(&os), + binary.executable(), ); - if cache_entry.path().exists() { + if let Ok(true) = cache_entry.path().try_exists() { return Ok(cache_entry.into_path_buf()); } - // Get download URL - let download_url = binary.download_url(&version, &platform_name, &os); + let ext = if os.is_windows() { + SourceDistExtension::Zip + } else { + SourceDistExtension::TarGz + }; + + let download_url = binary.download_url(&version, &platform_name, &ext)?; - // Create cache directory first let cache_dir = cache_entry.dir(); tokio::fs::create_dir_all(&cache_dir).await?; // Create a temporary directory for extraction - let temp_dir = tempfile::tempdir_in(cache_dir.parent().unwrap()) - .map_err(|e| anyhow::anyhow!("Failed to create temp dir: {}", e))?; + let temp_dir = tempfile::tempdir_in(cache_dir.parent().unwrap())?; - // Download and extract in one step let response = client .for_host(&download_url.clone().into()) .get(download_url.clone()) .send() .await - .map_err(|e| Error::Download { + .map_err(|err| Error::Download { tool: binary.name().to_string(), version: version.to_string(), url: download_url.to_string(), - source: e, + source: err, })?; - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Failed to download {}: {} returned {}", - binary.name(), - download_url, - response.status() - ) - .into()); - } - - // Determine archive type from URL - let ext = if os.is_windows() { - SourceDistExtension::Zip - } else { - SourceDistExtension::TarGz - }; + let response = response.error_for_status().map_err(|err| Error::Download { + tool: binary.name().to_string(), + version: version.to_string(), + url: download_url.to_string(), + source: reqwest_middleware::Error::Reqwest(err), + })?; // Stream download directly to extraction let mut reader = response @@ -220,28 +182,13 @@ pub async fn install( // Find the binary in the extracted files // The archive contains a directory with the platform name - let binary_name = binary.binary_name(&os); - let archive_dir_name = binary.archive_dir_name(&platform_name); - let extracted_binary = temp_dir.path().join(&archive_dir_name).join(&binary_name); - - if !extracted_binary.exists() { - // Try without the directory structure (in case archive format changes) - let direct_binary = temp_dir.path().join(&binary_name); - if direct_binary.exists() { - // Copy binary to cache location - tokio::fs::copy(&direct_binary, cache_entry.path()).await?; - } else { - return Err(Error::BinaryNotFound { - tool: binary.name().to_string(), - expected: extracted_binary, - }); - } - } else { - // Copy binary to cache location - tokio::fs::copy(&extracted_binary, cache_entry.path()).await?; - } + let extracted_binary = temp_dir + .path() + .join(format!("{}-{platform_name}", binary.name())) + .join(binary.executable()); + + uv_fs::rename_with_retry(&extracted_binary, cache_entry.path()).await?; - // Make executable on Unix #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -253,50 +200,44 @@ pub async fn install( Ok(cache_entry.into_path_buf()) } -/// Map UV's platform types to standard target triple naming convention. -fn get_platform_name(os: Os, arch: Arch, libc: Libc) -> String { +/// Cast platform types to the binary target triple format. +/// +/// This performs some normalization to match cargo-dist's styling. +fn platform_name_for_binary(os: Os, arch: Arch, libc: Libc) -> String { use target_lexicon::{ Architecture, ArmArchitecture, OperatingSystem, Riscv64Architecture, X86_32Architecture, }; - - // Get base architecture string - let arch_str = match arch.family() { + 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(), }; - - // Determine vendor let vendor = match &*os { OperatingSystem::Darwin(_) => "apple", OperatingSystem::Windows => "pc", _ => "unknown", }; - - // Map OS names (only Darwin needs special handling) let os_name = match &*os { OperatingSystem::Darwin(_) => "darwin", _ => &os.to_string(), }; - // Build base triple - let mut triple = format!("{arch_str}-{vendor}-{os_name}"); - - // Add environment/ABI suffix - match (&*os, libc) { - (OperatingSystem::Windows, _) => triple.push_str("-msvc"), - (OperatingSystem::Linux, Libc::Some(env)) => { - triple.push('-'); - triple.push_str(&env.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)) { - triple.push_str("eabihf"); + format!("{env}eabihf") + } else { + env.to_string() } - } - _ => {} - } + }), + _ => None, + }; - triple + format!( + "{arch_name}-{vendor}-{os_name}{abi}", + abi = abi.map(|abi| format!("-{abi}")).unwrap_or_default() + ) } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c7d4894e76b8e..ee9b9b636c00e 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4279,7 +4279,6 @@ pub struct FormatArgs { #[arg(long)] pub diff: bool, - /// The version of Ruff to use for formatting. /// /// By default, the latest version of Ruff will be used. diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 0e53432d9968e..9e1e03c912a7e 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -37,7 +37,6 @@ use crate::implementation::{ use crate::installation::PythonInstallationKey; use crate::managed::ManagedPythonInstallation; use crate::{Interpreter, PythonRequest, PythonVersion, VersionRequest}; -use uv_platform::{self as platform, Arch, Libc, Os}; #[derive(Error, Debug)] pub enum Error { diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 0875f212cdd61..8cdc33106c488 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -22,7 +22,6 @@ use crate::{ Error, ImplementationName, Interpreter, PythonDownloads, PythonPreference, PythonSource, PythonVariant, PythonVersion, downloads, }; -use uv_platform::{Arch, Libc, Os}; /// A Python interpreter and accompanying tools. #[derive(Clone, Debug)] diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 53a3c1e4db772..3a7cce3f08e1b 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -33,7 +33,6 @@ use crate::{ Prefix, PythonInstallationKey, PythonVariant, PythonVersion, Target, VersionRequest, VirtualEnvironment, }; -use uv_platform::{Arch, Libc, Os}; #[cfg(windows)] use windows_sys::Win32::Foundation::{APPMODEL_ERROR_NO_PACKAGE, ERROR_CANT_ACCESS_FILE}; diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 0a30cc4b2c854..69d12a0a37553 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -32,8 +32,6 @@ use crate::python_version::PythonVersion; use crate::{ PythonInstallationMinorVersionKey, PythonRequest, PythonVariant, macos_dylib, sysconfig, }; -use uv_platform::Error as PlatformError; -use uv_platform::{Arch, Libc, LibcDetectionError, Os}; #[derive(Error, Debug)] pub enum Error { diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index ede6f52207d11..94df2d23cafe6 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -91,7 +91,6 @@ rustc-hash = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tar = { workspace = true } -target-lexicon = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index 0a9ca82e8aa88..fed0a25e81083 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -1,21 +1,20 @@ -use std::fmt::Write; -use std::io::Write as IoWrite; use std::str::FromStr; use anyhow::{Context, Result}; use tokio::process::Command; -use uv_bin_install::{Binary, install}; +use uv_bin_install::{Binary, bin_install}; use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::BaseClientBuilder; use uv_pep440::Version; +use crate::child::run_to_completion; use crate::commands::ExitStatus; use crate::printer::Printer; use crate::settings::NetworkSettings; -/// Format Python source files using Ruff. +/// Run the formatter. pub(crate) async fn format( check: bool, diff: bool, @@ -23,26 +22,11 @@ pub(crate) async fn format( version: Option, network_settings: NetworkSettings, cache: Cache, - printer: Printer, + _printer: Printer, ) -> Result { - // Check if we're in offline mode - if network_settings.connectivity.is_offline() && version.is_none() { - // In offline mode without a specific version, we can't determine the latest version - writeln!( - printer.stderr(), - "Ruff formatting is not available in offline mode without a specific version" - )?; - return Ok(ExitStatus::Failure); - } - // Parse version if provided - let version = version - .as_deref() - .map(Version::from_str) - .transpose() - .context("Invalid version format")?; + let version = version.as_deref().map(Version::from_str).transpose()?; - // Create HTTP client let client = BaseClientBuilder::new() .retries_from_env()? .connectivity(network_settings.connectivity) @@ -50,54 +34,28 @@ pub(crate) async fn format( .allow_insecure_host(network_settings.allow_insecure_host.clone()) .build(); - // Download or retrieve Ruff binary from cache - let ruff_path = install(Binary::Ruff, version.as_ref(), &client, &cache) + // Get the path to Ruff, downloading it if necessary + let ruff_path = bin_install(Binary::Ruff, version.as_ref(), &client, &cache) .await .context("Failed to install Ruff")?; - // Construct the ruff format command. let mut command = Command::new(&ruff_path); command.arg("format"); - // Add check flag if requested. if check { command.arg("--check"); } - - // Add diff flag if requested. if diff { command.arg("--diff"); } - // Ruff format defaults to the current directory when no files are specified. - // If the user wants to format specific files, they can pass them after -- - // e.g., uv format -- src/main.py - - // Add any additional arguments passed after --. + // Add any additional arguments passed after `--` if let Some(args) = args { for arg in args.iter() { command.arg(arg); } } - // Run the ruff format command. - let output = command - .output() - .await - .context("Failed to run ruff format")?; - - // Stream stdout and stderr. - if !output.stdout.is_empty() { - std::io::stdout().write_all(&output.stdout)?; - } - if !output.stderr.is_empty() { - std::io::stderr().write_all(&output.stderr)?; - } - - // Return the exit status. - if output.status.success() { - Ok(ExitStatus::Success) - } else { - Ok(ExitStatus::Failure) - } + let handle = command.spawn().context("Failed to spawn `ruff format`")?; + run_to_completion(handle).await } From 5de39b5d09119ed9a9c163c1b250bb15d87f4e33 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 15:38:10 -0500 Subject: [PATCH 07/19] Implement a reporter for the download --- Cargo.lock | 2 - crates/uv-bin-install/Cargo.toml | 1 + crates/uv-bin-install/src/lib.rs | 91 +++++++++++++++++++++--- crates/uv/src/commands/project/format.rs | 16 +++-- crates/uv/src/commands/reporters.rs | 29 ++++++++ 5 files changed, 125 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4caaeced5154c..c093fc2e15271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4696,7 +4696,6 @@ dependencies = [ "serde_json", "similar", "tar", - "target-lexicon", "tempfile", "textwrap", "thiserror 2.0.12", @@ -4835,7 +4834,6 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tokio-util", - "tracing", "url", "uv-cache", "uv-client", diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml index 4976642527f75..9aabdd0c6c630 100644 --- a/crates/uv-bin-install/Cargo.toml +++ b/crates/uv-bin-install/Cargo.toml @@ -27,6 +27,7 @@ uv-platform = { workspace = true } anyhow = { workspace = true } futures = { workspace = true } +reqwest = { workspace = true } reqwest-middleware = { workspace = true } target-lexicon = { workspace = true } tempfile = { workspace = true } diff --git a/crates/uv-bin-install/src/lib.rs b/crates/uv-bin-install/src/lib.rs index 234cefdb0c31f..4d16818b6d539 100644 --- a/crates/uv-bin-install/src/lib.rs +++ b/crates/uv-bin-install/src/lib.rs @@ -1,13 +1,16 @@ //! Binary download and installation utilities for uv. //! -//! This crate provides functionality for downloading and caching binary tools -//! from various sources (GitHub releases, etc.) for use by 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::str::FromStr; +use std::task::{Context, Poll}; use futures::TryStreamExt; use thiserror::Error; +use tokio::io::{AsyncRead, ReadBuf}; use tokio_util::compat::FuturesAsyncReadCompatExt; use url::Url; @@ -106,12 +109,65 @@ pub enum Error { Platform(#[from] uv_platform::Error), } +/// 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> { + let before = buf.filled().len(); + match Pin::new(&mut self.reader).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + let after = buf.filled().len(); + let bytes = after - before; + if bytes > 0 { + self.reporter.on_download_progress(self.index, bytes as u64); + } + Poll::Ready(Ok(())) + } + poll => poll, + } + } +} + /// Install a binary for the given tool. pub async fn bin_install( binary: Binary, version: Option<&Version>, client: &BaseClient, cache: &Cache, + reporter: Option<&dyn Reporter>, ) -> Result { let os = Os::from_env(); let arch = Arch::from_env(); @@ -166,6 +222,13 @@ pub async fn bin_install( source: reqwest_middleware::Error::Reqwest(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 mut reader = response .bytes_stream() @@ -173,12 +236,24 @@ pub async fn bin_install( .into_async_read() .compat(); - stream::archive(&mut reader, ext, temp_dir.path()) - .await - .map_err(|e| Error::Extract { - tool: binary.name().to_string(), - source: e.into(), - })?; + if let Some(reporter) = reporter { + let id = reporter.on_download_start(binary.name(), &version, size); + let mut progress_reader = ProgressReader::new(reader, id, reporter); + stream::archive(&mut progress_reader, ext, temp_dir.path()) + .await + .map_err(|e| Error::Extract { + tool: binary.name().to_string(), + source: e.into(), + })?; + reporter.on_download_complete(id); + } else { + stream::archive(&mut reader, ext, temp_dir.path()) + .await + .map_err(|e| Error::Extract { + tool: binary.name().to_string(), + source: e.into(), + })?; + } // Find the binary in the extracted files // The archive contains a directory with the platform name diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index fed0a25e81083..e0fb6d666200a 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -11,6 +11,7 @@ use uv_pep440::Version; use crate::child::run_to_completion; use crate::commands::ExitStatus; +use crate::commands::reporters::BinaryDownloadReporter; use crate::printer::Printer; use crate::settings::NetworkSettings; @@ -22,7 +23,7 @@ pub(crate) async fn format( version: Option, network_settings: NetworkSettings, cache: Cache, - _printer: Printer, + printer: Printer, ) -> Result { // Parse version if provided let version = version.as_deref().map(Version::from_str).transpose()?; @@ -35,9 +36,16 @@ pub(crate) async fn format( .build(); // Get the path to Ruff, downloading it if necessary - let ruff_path = bin_install(Binary::Ruff, version.as_ref(), &client, &cache) - .await - .context("Failed to install Ruff")?; + let reporter = BinaryDownloadReporter::single(printer); + let ruff_path = bin_install( + Binary::Ruff, + version.as_ref(), + &client, + &cache, + Some(&reporter), + ) + .await + .context("Failed to install Ruff")?; let mut command = Command::new(&ruff_path); command.arg("format"); diff --git a/crates/uv/src/commands/reporters.rs b/crates/uv/src/commands/reporters.rs index 3943c941e2322..5615dd079bbbb 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); + } +} From 505dbaaa43b08431eb662c9c58f3c77563b2b8d5 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 15:52:45 -0500 Subject: [PATCH 08/19] Clean up test cases --- crates/uv/tests/it/format.rs | 179 +++++------------------------------ 1 file changed, 22 insertions(+), 157 deletions(-) diff --git a/crates/uv/tests/it/format.rs b/crates/uv/tests/it/format.rs index cf0cda098b88c..b80ea1e0761eb 100644 --- a/crates/uv/tests/it/format.rs +++ b/crates/uv/tests/it/format.rs @@ -28,17 +28,6 @@ fn format_project() -> Result<()> { hello( ) "#})?; - // Snapshot the original content - let original_content = fs_err::read_to_string(&main_py)?; - assert_snapshot!(original_content, @r#" - import sys - def hello(): - print( "Hello, World!" ) - if __name__=="__main__": - hello( ) - "#); - - // Run format (formats current directory by default) uv_snapshot!(context.filters(), context.format(), @r" success: true exit_code: 0 @@ -85,7 +74,6 @@ fn format_check() -> Result<()> { print( "Hello, World!" ) "#})?; - // Run format with --check uv_snapshot!(context.filters(), context.format().arg("--check"), @r" success: false exit_code: 1 @@ -126,7 +114,6 @@ fn format_diff() -> Result<()> { print( "Hello, World!" ) "#})?; - // Run format with --diff uv_snapshot!(context.filters(), context.format().arg("--diff"), @r#" success: false exit_code: 1 @@ -184,7 +171,7 @@ fn format_with_ruff_args() -> Result<()> { ----- stderr ----- "); - // Check that the line wasn't wrapped (because we set a high line length) + // 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(): @@ -215,167 +202,36 @@ fn format_specific_files() -> Result<()> { "#})?; let utils_py = context.temp_dir.child("utils.py"); - utils_py.write_str(indoc! {r" - def util(): - return 42 - "})?; + utils_py.write_str(indoc! {r#" + def utils(): + print( "utils" ) + "#})?; - // Run format on specific files using -- - uv_snapshot!(context.filters(), context.format().arg("--").arg("main.py").arg("utils.py"), @r" + uv_snapshot!(context.filters(), context.format().arg("--").arg("main.py"), @r" success: true exit_code: 0 ----- stdout ----- - 2 files reformatted + 1 file reformatted ----- stderr ----- "); - // Check that both files were formatted 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 util(): - return 42 - "#); - - Ok(()) -} - -#[test] -fn format_specific_directory() -> Result<()> { - let context = TestContext::new("3.12"); - - 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 subdirectory with Python files - let src_dir = context.temp_dir.child("src"); - src_dir.create_dir_all()?; - - let module_py = src_dir.child("module.py"); - module_py.write_str(indoc! {r" - def func(): - pass - "})?; - - // Run format on specific directory using -- - uv_snapshot!(context.filters(), context.format().arg("--").arg("src/"), @r" - success: true - exit_code: 0 - ----- stdout ----- - 1 file reformatted - - ----- stderr ----- - "); - - // Check that the file in the directory was formatted - let module_content = fs_err::read_to_string(&module_py)?; - assert_snapshot!(module_content, @r#" - def func(): - pass + def utils(): + print( "utils" ) "#); Ok(()) } -#[test] -fn format_no_files() -> Result<()> { - let context = TestContext::new("3.12"); - - 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 - let main_py = context.temp_dir.child("main.py"); - main_py.write_str(indoc! {r#" - def hello(): - print( "Hello" ) - "#})?; - - // Run format without specifying files (should format current directory) - uv_snapshot!(context.filters(), context.format(), @r" - success: true - exit_code: 0 - ----- stdout ----- - 1 file reformatted - - ----- stderr ----- - "); - - // Check that the file was formatted - let content = fs_err::read_to_string(&main_py)?; - assert_snapshot!(content, @r#" - def hello(): - print("Hello") - "#); - - Ok(()) -} - -#[test] -fn format_cache_reuse() -> Result<()> { - let context = TestContext::new("3.12"); - - 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 Python file - let main_py = context.temp_dir.child("main.py"); - main_py.write_str(indoc! {r" - def hello(): pass - "})?; - - // First run - installs Ruff - uv_snapshot!(context.filters(), context.format(), @r" - success: true - exit_code: 0 - ----- stdout ----- - 1 file reformatted - - ----- stderr ----- - "); - - // Modify the file again - main_py.write_str(indoc! {r" - def goodbye(): pass - "})?; - - // Second run - should reuse cached Ruff - uv_snapshot!(context.filters(), context.format(), @r" - success: true - exit_code: 0 - ----- stdout ----- - 1 file reformatted - - ----- stderr ----- - "); - - Ok(()) -} - #[test] fn format_version_option() -> Result<()> { let context = TestContext::new_with_versions(&["3.11", "3.12"]); @@ -395,13 +251,22 @@ fn format_version_option() -> Result<()> { "})?; // 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 + success: false + exit_code: 2 ----- stdout ----- - 1 file reformatted ----- stderr ----- + error: unexpected argument '--output-format' found + + tip: to pass '--output-format' as a value, use '-- --output-format' + + Usage: ruff format [OPTIONS] [FILES]... + + For more information, try '--help'. "); Ok(()) From fa74eda9ac7aae8cde358ac590719e65aac2b152 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 16:41:02 -0500 Subject: [PATCH 09/19] Add preview flag --- crates/uv-configuration/src/preview.rs | 4 ++++ crates/uv/src/commands/project/format.rs | 10 ++++++++++ crates/uv/src/lib.rs | 1 + crates/uv/tests/it/format.rs | 18 +++++++++--------- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/uv-configuration/src/preview.rs b/crates/uv-configuration/src/preview.rs index fab7dd34ead23..6a34be310c39e 100644 --- a/crates/uv-configuration/src/preview.rs +++ b/crates/uv-configuration/src/preview.rs @@ -15,6 +15,7 @@ bitflags::bitflags! { const PYLOCK = 1 << 3; const ADD_BOUNDS = 1 << 4; const EXTRA_BUILD_DEPENDENCIES = 1 << 5; + const FORMAT = 1 << 6; } } @@ -30,6 +31,7 @@ impl PreviewFeatures { Self::PYLOCK => "pylock", Self::ADD_BOUNDS => "add-bounds", Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies", + Self::FORMAT => "format", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -73,6 +75,7 @@ impl FromStr for PreviewFeatures { "pylock" => Self::PYLOCK, "add-bounds" => Self::ADD_BOUNDS, "extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES, + "format" => Self::FORMAT, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -239,6 +242,7 @@ mod tests { PreviewFeatures::EXTRA_BUILD_DEPENDENCIES.flag_as_str(), "extra-build-dependencies" ); + assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format"); } #[test] diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index e0fb6d666200a..863ae86309cee 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -7,7 +7,9 @@ use uv_bin_install::{Binary, bin_install}; use uv_cache::Cache; use uv_cli::ExternalCommand; 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; @@ -24,7 +26,15 @@ pub(crate) async fn format( 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()?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 14bd2dbffa0eb..0f3f4b65ddff1 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2229,6 +2229,7 @@ async fn run_project( globals.network_settings, cache, printer, + globals.preview, )) .await } diff --git a/crates/uv/tests/it/format.rs b/crates/uv/tests/it/format.rs index b80ea1e0761eb..a2230915ffb2e 100644 --- a/crates/uv/tests/it/format.rs +++ b/crates/uv/tests/it/format.rs @@ -35,6 +35,7 @@ fn format_project() -> Result<()> { 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 @@ -82,6 +83,7 @@ fn format_check() -> Result<()> { 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 @@ -128,6 +130,7 @@ fn format_diff() -> Result<()> { ----- stderr ----- + warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning. 1 file would be reformatted "#); @@ -169,6 +172,7 @@ fn format_with_ruff_args() -> Result<()> { 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) @@ -214,6 +218,7 @@ fn format_specific_files() -> Result<()> { 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)?; @@ -255,18 +260,13 @@ fn format_version_option() -> Result<()> { // 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: false - exit_code: 2 + success: true + exit_code: 0 ----- stdout ----- + 1 file reformatted ----- stderr ----- - error: unexpected argument '--output-format' found - - tip: to pass '--output-format' as a value, use '-- --output-format' - - Usage: ruff format [OPTIONS] [FILES]... - - For more information, try '--help'. + warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning. "); Ok(()) From 8e2c540fac881bb6fa2762c266dd7bd7470e7a15 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 29 Jul 2025 13:12:53 -0500 Subject: [PATCH 10/19] Run generate-all --- docs/reference/cli.md | 70 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 01b5184c86871..cb2858ad1471d 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 source files using Ruff

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

@@ -1827,6 +1828,75 @@ 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 source files using Ruff. + +Formats Python source files using the Ruff formatter. By default, all Python files in the current directory and subdirectories are formatted. + +To check if files are formatted without modifying them, use `--check`. To see a diff of formatting changes, use `--diff`. + +Additional arguments can be passed to Ruff after `--`. For example: `uv format src/ -- --line-length 100` + +

Usage

+ +``` +uv format [OPTIONS] [COMMAND] +``` + +

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, the latest version of Ruff will be used.

+
+ ## uv tool Run and install commands provided by Python packages From 89cbf93f3caf22d0ccf6ad8fbfd987b7aa87be2c Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 29 Jul 2025 13:29:04 -0500 Subject: [PATCH 11/19] Update help snapshots --- crates/uv/tests/it/help.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 54c10e9728470..bdc77f228080e 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 source files using Ruff 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 source files using Ruff 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 source files using Ruff 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 source files using Ruff 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 source files using Ruff tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface From 3d5652dc5f1c41b512b6b1a04be45dee385dcf3b Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 1 Aug 2025 13:59:20 -0500 Subject: [PATCH 12/19] Update help text --- crates/uv-cli/src/lib.rs | 21 ++++++++++----------- crates/uv/tests/it/help.rs | 10 +++++----- crates/uv/tests/it/show_settings.rs | 4 ++-- docs/reference/cli.md | 10 +++++----- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ee9b9b636c00e..a78a3de1d6476 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1004,16 +1004,15 @@ pub enum ProjectCommand { Export(ExportArgs), /// Display the project's dependency tree. Tree(TreeArgs), - /// Format Python source files using Ruff. + /// Format Python files. /// - /// Formats Python source files using the Ruff formatter. By default, all Python files - /// in the current directory and subdirectories are formatted. + /// Formats Python files using the Ruff formatter. By default, all Python files in the project + /// are formatted. /// - /// To check if files are formatted without modifying them, use `--check`. - /// To see a diff of formatting changes, use `--diff`. + /// To check if files are formatted without modifying them, use `--check`. To see a diff of + /// formatting changes, use `--diff`. /// - /// Additional arguments can be passed to Ruff after `--`. For example: - /// `uv format src/ -- --line-length 100` + /// Additional arguments can be passed to Ruff after `--`. #[command( after_help = "Use `uv help format` for more details.", after_long_help = "" @@ -4281,14 +4280,14 @@ pub struct FormatArgs { /// The version of Ruff to use for formatting. /// - /// By default, the latest version of Ruff will be used. + /// By default, a version of Ruff pinned by uv will be used. #[arg(long)] pub version: Option, - /// Additional arguments to pass to Ruff, including specific files or directories. + /// Additional arguments to pass to Ruff. /// - /// Use `--` to separate these arguments from uv arguments. - /// For example: `uv format -- --line-length 100` or `uv format -- src/` + /// 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. #[command(subcommand)] pub args: Option, } diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index bdc77f228080e..3f2a796848e32 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -25,7 +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 source files using Ruff + format Format Python files tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -106,7 +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 source files using Ruff + format Format Python files tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -185,7 +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 source files using Ruff + format Format Python files tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -968,7 +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 source files using Ruff + format Format Python files tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -1090,7 +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 source files using Ruff + format Format Python files 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/show_settings.rs b/crates/uv/tests/it/show_settings.rs index ff9c4383f8018..efc62223ea477 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7611,7 +7611,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES | FORMAT, ), }, python_preference: Managed, @@ -7831,7 +7831,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES | FORMAT, ), }, python_preference: Managed, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cb2858ad1471d..3ed19d7f6a7f6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -21,7 +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 source files using Ruff

+
uv format

Format Python files

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

@@ -1830,13 +1830,13 @@ interpreter. Use --universal to display the tree for all platforms, ## uv format -Format Python source files using Ruff. +Format Python files. -Formats Python source files using the Ruff formatter. By default, all Python files in the current directory and subdirectories are formatted. +Formats Python files using the Ruff formatter. By default, all Python files in the project are formatted. To check if files are formatted without modifying them, use `--check`. To see a diff of formatting changes, use `--diff`. -Additional arguments can be passed to Ruff after `--`. For example: `uv format src/ -- --line-length 100` +Additional arguments can be passed to Ruff after `--`.

Usage

@@ -1894,7 +1894,7 @@ uv format [OPTIONS] [COMMAND]
--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, the latest version of Ruff will be used.

+

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

## uv tool From e49fdf78a13aa0e9603008bc152b15eae1d00a28 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 2 Aug 2025 07:20:37 -0500 Subject: [PATCH 13/19] Fix Windows extraction --- crates/uv-bin-install/src/lib.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/uv-bin-install/src/lib.rs b/crates/uv-bin-install/src/lib.rs index 4d16818b6d539..54406490ff0fc 100644 --- a/crates/uv-bin-install/src/lib.rs +++ b/crates/uv-bin-install/src/lib.rs @@ -256,11 +256,20 @@ pub async fn bin_install( } // Find the binary in the extracted files - // The archive contains a directory with the platform name - let extracted_binary = temp_dir - .path() - .join(format!("{}-{platform_name}", binary.name())) - .join(binary.executable()); + let extracted_binary = match ext { + SourceDistExtension::Zip => { + // Windows ZIP archives contain the binary directly in the root + temp_dir.path().join(binary.executable()) + } + SourceDistExtension::TarGz | SourceDistExtension::Tgz => { + // tar.gz archives contain the binary in a subdirectory + temp_dir + .path() + .join(format!("{}-{platform_name}", binary.name())) + .join(binary.executable()) + } + _ => unreachable!("Unsupported archive format"), + }; uv_fs::rename_with_retry(&extracted_binary, cache_entry.path()).await?; From 4b921f6cda5532a8947d8085d8bdd0b98686ee62 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 19 Aug 2025 12:17:00 -0500 Subject: [PATCH 14/19] Review feedback --- Cargo.lock | 3 + crates/uv-bin-install/Cargo.toml | 3 + crates/uv-bin-install/src/lib.rs | 243 +++++++++++++++-------- crates/uv-cli/src/lib.rs | 13 +- crates/uv/src/commands/project/format.rs | 23 +-- crates/uv/src/lib.rs | 2 +- crates/uv/src/settings.rs | 6 +- crates/uv/tests/it/format.rs | 12 +- docs/reference/cli.md | 8 +- 9 files changed, 200 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c093fc2e15271..8bbe2a6dca103 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4826,14 +4826,17 @@ name = "uv-bin-install" version = "0.0.1" dependencies = [ "anyhow", + "fs-err", "futures", "reqwest", "reqwest-middleware", + "reqwest-retry", "target-lexicon", "tempfile", "thiserror 2.0.12", "tokio", "tokio-util", + "tracing", "url", "uv-cache", "uv-client", diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml index 9aabdd0c6c630..310249e16dff0 100644 --- a/crates/uv-bin-install/Cargo.toml +++ b/crates/uv-bin-install/Cargo.toml @@ -26,13 +26,16 @@ uv-pep440 = { workspace = true } uv-platform = { workspace = true } anyhow = { workspace = true } +fs-err = { workspace = true, features = ["tokio"] } futures = { workspace = true } reqwest = { workspace = true } reqwest-middleware = { workspace = true } +reqwest-retry = { workspace = true } target-lexicon = { 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 index 54406490ff0fc..4c3aff99f9e62 100644 --- a/crates/uv-bin-install/src/lib.rs +++ b/crates/uv-bin-install/src/lib.rs @@ -5,19 +5,22 @@ use std::path::PathBuf; use std::pin::Pin; -use std::str::FromStr; 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; -use uv_distribution_filename::SourceDistExtension; -use uv_extract::stream; +use uv_client::{BaseClient, is_extended_transient_error}; +use uv_extract::{Error as ExtractError, stream}; use uv_pep440::Version; use uv_platform::{Arch, Libc, Os}; @@ -31,7 +34,8 @@ impl Binary { /// Get the default version for this binary. pub fn default_version(&self) -> Version { match self { - Binary::Ruff => Version::from_str("0.12.5").expect("valid version"), + // TODO(zanieb): Figure out a nice way to automate updating this + Binary::Ruff => Version::new([0, 12, 5]), } } @@ -49,12 +53,13 @@ impl Binary { &self, version: &Version, platform: &str, - ext: &SourceDistExtension, + format: ArchiveFormat, ) -> Result { match self { Binary::Ruff => { let url = format!( - "https://github.com/astral-sh/ruff/releases/download/{version}/ruff-{platform}.{ext}" + "https://github.com/astral-sh/ruff/releases/download/{version}/ruff-{platform}.{}", + format.extension() ); Url::parse(&url).map_err(|err| Error::UrlParse { url, source: err }) } @@ -67,46 +72,87 @@ impl Binary { } } +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 => SourceDistExtension::Zip, + ArchiveFormat::TarGz => SourceDistExtension::TarGz, + } + } +} + /// Errors that can occur during binary download and installation. #[derive(Debug, Error)] pub enum Error { - /// Failed to download binary. - #[error("Failed to download {tool} {version} from {url}")] + #[error("Failed to download from: {url}")] Download { - tool: String, - version: String, - url: String, + url: Url, #[source] source: reqwest_middleware::Error, }, - /// Failed to parse download URL. - #[error("Failed to parse download URL: {url}")] + #[error("Failed to parse URL: {url}")] UrlParse { url: String, #[source] source: url::ParseError, }, - /// Failed to extract archive. - #[error("Failed to extract {tool} archive")] + #[error("Failed to extract archive")] Extract { - tool: String, #[source] - source: anyhow::Error, + source: ExtractError, }, - /// Binary not found in extracted archive. - #[error("Binary not found in {tool} archive at expected location: {expected}")] - BinaryNotFound { tool: String, expected: PathBuf }, + #[error("Binary not found in archive at expected location: {expected}")] + BinaryNotFound { expected: PathBuf }, - /// I/O error during installation. #[error(transparent)] Io(#[from] std::io::Error), - /// Platform detection error. #[error("Failed to detect platform")] Platform(#[from] uv_platform::Error), + + #[error("Request failed after {retries} retries")] + NetworkErrorWithRetries { + #[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 Error::NetworkErrorWithRetries { retries, .. } = self { + return retries + 1; + } + 1 + } } /// Progress reporter for binary downloads. @@ -146,34 +192,80 @@ where cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { - let before = buf.filled().len(); - match Pin::new(&mut self.reader).poll_read(cx, buf) { - Poll::Ready(Ok(())) => { - let after = buf.filled().len(); - let bytes = after - before; - if bytes > 0 { - self.reporter.on_download_progress(self.index, bytes as u64); + 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); + }) + } +} + +/// Install a binary for the given tool with retry on failure. +pub async fn bin_install( + binary: Binary, + version: &Version, + client: &BaseClient, + cache: &Cache, + reporter: &dyn Reporter, +) -> 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 = bin_install_inner(binary, version, client, cache, reporter).await; + + let result = match result { + Ok(path) => Ok(path), + Err(err) => { + total_attempts += err.attempts(); + let n_past_retries = total_attempts - 1; + + if is_extended_transient_error(&err) { + let retry_decision = retry_policy.should_retry(start_time, n_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; // Retry + } + } + + if retried_here { + Err(Error::NetworkErrorWithRetries { + err: Box::new(err), + retries: n_past_retries, + }) + } else { + Err(err) } - Poll::Ready(Ok(())) } - poll => poll, - } + }; + return result; } } -/// Install a binary for the given tool. -pub async fn bin_install( +/// Install a binary for the given tool (internal implementation without retry). +async fn bin_install_inner( binary: Binary, - version: Option<&Version>, + version: &Version, client: &BaseClient, cache: &Cache, - reporter: Option<&dyn Reporter>, + reporter: &dyn Reporter, ) -> Result { let os = Os::from_env(); let arch = Arch::from_env(); let libc = Libc::from_env()?; - let version = version.cloned().unwrap_or_else(|| binary.default_version()); - let platform_name = platform_name_for_binary(os, arch, libc); + let platform_name = cargo_dist_platform(os, arch, libc); // Check the cache first let cache_entry = CacheEntry::new( @@ -185,23 +277,23 @@ pub async fn bin_install( binary.executable(), ); - if let Ok(true) = cache_entry.path().try_exists() { + if cache_entry.path().exists() { return Ok(cache_entry.into_path_buf()); } - let ext = if os.is_windows() { - SourceDistExtension::Zip + let format = if os.is_windows() { + ArchiveFormat::Zip } else { - SourceDistExtension::TarGz + ArchiveFormat::TarGz }; - let download_url = binary.download_url(&version, &platform_name, &ext)?; + let download_url = binary.download_url(version, &platform_name, format)?; let cache_dir = cache_entry.dir(); - tokio::fs::create_dir_all(&cache_dir).await?; + fs_err::tokio::create_dir_all(&cache_dir).await?; // Create a temporary directory for extraction - let temp_dir = tempfile::tempdir_in(cache_dir.parent().unwrap())?; + let temp_dir = tempfile::tempdir_in(cache.bucket(CacheBucket::Binaries))?; let response = client .for_host(&download_url.clone().into()) @@ -209,16 +301,12 @@ pub async fn bin_install( .send() .await .map_err(|err| Error::Download { - tool: binary.name().to_string(), - version: version.to_string(), - url: download_url.to_string(), + url: download_url.clone(), source: err, })?; let response = response.error_for_status().map_err(|err| Error::Download { - tool: binary.name().to_string(), - version: version.to_string(), - url: download_url.to_string(), + url: download_url.clone(), source: reqwest_middleware::Error::Reqwest(err), })?; @@ -230,64 +318,59 @@ pub async fn bin_install( .and_then(|val| val.parse::().ok()); // Stream download directly to extraction - let mut reader = response + let reader = response .bytes_stream() .map_err(std::io::Error::other) .into_async_read() .compat(); - if let Some(reporter) = reporter { - let id = reporter.on_download_start(binary.name(), &version, size); - let mut progress_reader = ProgressReader::new(reader, id, reporter); - stream::archive(&mut progress_reader, ext, temp_dir.path()) - .await - .map_err(|e| Error::Extract { - tool: binary.name().to_string(), - source: e.into(), - })?; - reporter.on_download_complete(id); - } else { - stream::archive(&mut reader, ext, temp_dir.path()) - .await - .map_err(|e| Error::Extract { - tool: binary.name().to_string(), - source: e.into(), - })?; - } + 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 ext { - SourceDistExtension::Zip => { + let extracted_binary = match format { + ArchiveFormat::Zip => { // Windows ZIP archives contain the binary directly in the root temp_dir.path().join(binary.executable()) } - SourceDistExtension::TarGz | SourceDistExtension::Tgz => { + ArchiveFormat::TarGz => { // tar.gz archives contain the binary in a subdirectory temp_dir .path() .join(format!("{}-{platform_name}", binary.name())) .join(binary.executable()) } - _ => unreachable!("Unsupported archive format"), }; uv_fs::rename_with_retry(&extracted_binary, cache_entry.path()).await?; #[cfg(unix)] { + use std::fs::Permissions; use std::os::unix::fs::PermissionsExt; - let mut perms = tokio::fs::metadata(cache_entry.path()).await?.permissions(); - perms.set_mode(0o755); - tokio::fs::set_permissions(cache_entry.path(), perms).await?; + let permissions = fs_err::tokio::metadata(cache_entry.path()) + .await? + .permissions(); + if permissions.mode() & 0o111 != 0o111 { + fs_err::tokio::set_permissions( + cache_entry.path(), + Permissions::from_mode(permissions.mode() | 0o111), + ) + .await?; + } } Ok(cache_entry.into_path_buf()) } -/// Cast platform types to the binary target triple format. +/// Cast platform types to the binary target triple format used by cargo-dist. /// /// This performs some normalization to match cargo-dist's styling. -fn platform_name_for_binary(os: Os, arch: Arch, libc: Libc) -> String { +fn cargo_dist_platform(os: Os, arch: Arch, libc: Libc) -> String { use target_lexicon::{ Architecture, ArmArchitecture, OperatingSystem, Riscv64Architecture, X86_32Architecture, }; diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index a78a3de1d6476..c7bf169e753d9 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1004,15 +1004,16 @@ pub enum ProjectCommand { Export(ExportArgs), /// Display the project's dependency tree. Tree(TreeArgs), - /// Format Python files. + /// Format Python code in the project. /// - /// Formats Python files using the Ruff formatter. By default, all Python files in the project - /// are formatted. + /// 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`. /// - /// Additional arguments can be passed to Ruff after `--`. + /// By default, Additional arguments can be passed to Ruff after `--`. #[command( after_help = "Use `uv help format` for more details.", after_long_help = "" @@ -4288,8 +4289,8 @@ pub struct FormatArgs { /// /// 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. - #[command(subcommand)] - pub args: Option, + #[arg(last = true)] + pub extra_args: Vec, } #[derive(Args)] diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index 863ae86309cee..96ddd59bd2b1c 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -5,7 +5,6 @@ use tokio::process::Command; use uv_bin_install::{Binary, bin_install}; use uv_cache::Cache; -use uv_cli::ExternalCommand; use uv_client::BaseClientBuilder; use uv_configuration::{Preview, PreviewFeatures}; use uv_pep440::Version; @@ -21,7 +20,7 @@ use crate::settings::NetworkSettings; pub(crate) async fn format( check: bool, diff: bool, - args: Option, + extra_args: Vec, version: Option, network_settings: NetworkSettings, cache: Cache, @@ -47,15 +46,11 @@ pub(crate) async fn format( // Get the path to Ruff, downloading it if necessary let reporter = BinaryDownloadReporter::single(printer); - let ruff_path = bin_install( - Binary::Ruff, - version.as_ref(), - &client, - &cache, - Some(&reporter), - ) - .await - .context("Failed to install Ruff")?; + 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"); @@ -68,11 +63,7 @@ pub(crate) async fn format( } // Add any additional arguments passed after `--` - if let Some(args) = args { - for arg in args.iter() { - command.arg(arg); - } - } + 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/lib.rs b/crates/uv/src/lib.rs index 0f3f4b65ddff1..e0ffe9c30f667 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2224,7 +2224,7 @@ async fn run_project( Box::pin(commands::format( args.check, args.diff, - args.args, + args.extra_args, args.version, globals.network_settings, cache, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index acfada8988e64..2710f5fcbfdce 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1867,7 +1867,7 @@ impl ExportSettings { pub(crate) struct FormatSettings { pub(crate) check: bool, pub(crate) diff: bool, - pub(crate) args: Option, + pub(crate) extra_args: Vec, pub(crate) version: Option, } @@ -1877,14 +1877,14 @@ impl FormatSettings { let FormatArgs { check, diff, - args, + extra_args, version, } = args; Self { check, diff, - args, + extra_args, version, } } diff --git a/crates/uv/tests/it/format.rs b/crates/uv/tests/it/format.rs index a2230915ffb2e..6898fc3aaf109 100644 --- a/crates/uv/tests/it/format.rs +++ b/crates/uv/tests/it/format.rs @@ -7,7 +7,7 @@ use crate::common::{TestContext, uv_snapshot}; #[test] fn format_project() -> Result<()> { - let context = TestContext::new("3.12"); + let context = TestContext::new_with_versions(&[]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -57,7 +57,7 @@ fn format_project() -> Result<()> { #[test] fn format_check() -> Result<()> { - let context = TestContext::new("3.12"); + let context = TestContext::new_with_versions(&[]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -98,7 +98,7 @@ fn format_check() -> Result<()> { #[test] fn format_diff() -> Result<()> { - let context = TestContext::new("3.12"); + let context = TestContext::new_with_versions(&[]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -146,7 +146,7 @@ fn format_diff() -> Result<()> { #[test] fn format_with_ruff_args() -> Result<()> { - let context = TestContext::new("3.12"); + let context = TestContext::new_with_versions(&[]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -187,7 +187,7 @@ fn format_with_ruff_args() -> Result<()> { #[test] fn format_specific_files() -> Result<()> { - let context = TestContext::new("3.12"); + let context = TestContext::new_with_versions(&[]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" @@ -239,7 +239,7 @@ fn format_specific_files() -> Result<()> { #[test] fn format_version_option() -> Result<()> { - let context = TestContext::new_with_versions(&["3.11", "3.12"]); + let context = TestContext::new_with_versions(&[]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3ed19d7f6a7f6..e91bf25669db7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1841,9 +1841,15 @@ Additional arguments can be passed to Ruff after `--`.

Usage

``` -uv format [OPTIONS] [COMMAND] +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.

From 5e62445b018889963175730e8d89b89e7acdde04 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 19 Aug 2025 17:14:42 -0500 Subject: [PATCH 15/19] use the `Platform` type --- Cargo.lock | 2 - crates/uv-bin-install/Cargo.toml | 3 -- crates/uv-bin-install/src/lib.rs | 64 +++++--------------------------- crates/uv-platform/src/lib.rs | 43 +++++++++++++++++++++ crates/uv/tests/it/common/mod.rs | 2 +- docs/reference/cli.md | 8 ++-- 6 files changed, 58 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd897887f7b72..6de25bce7a390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5158,13 +5158,11 @@ dependencies = [ name = "uv-bin-install" version = "0.0.1" dependencies = [ - "anyhow", "fs-err", "futures", "reqwest", "reqwest-middleware", "reqwest-retry", - "target-lexicon", "tempfile", "thiserror 2.0.12", "tokio", diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml index 310249e16dff0..57fd0ac52366a 100644 --- a/crates/uv-bin-install/Cargo.toml +++ b/crates/uv-bin-install/Cargo.toml @@ -24,14 +24,11 @@ uv-extract = { workspace = true } uv-fs = { workspace = true } uv-pep440 = { workspace = true } uv-platform = { workspace = true } - -anyhow = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } futures = { workspace = true } reqwest = { workspace = true } reqwest-middleware = { workspace = true } reqwest-retry = { workspace = true } -target-lexicon = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } diff --git a/crates/uv-bin-install/src/lib.rs b/crates/uv-bin-install/src/lib.rs index 4c3aff99f9e62..e3d2a597fbd5f 100644 --- a/crates/uv-bin-install/src/lib.rs +++ b/crates/uv-bin-install/src/lib.rs @@ -22,7 +22,7 @@ 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::{Arch, Libc, Os}; +use uv_platform::Platform; /// Binary tools that can be installed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -35,7 +35,7 @@ impl Binary { pub fn default_version(&self) -> Version { match self { // TODO(zanieb): Figure out a nice way to automate updating this - Binary::Ruff => Version::new([0, 12, 5]), + Self::Ruff => Version::new([0, 12, 5]), } } @@ -44,7 +44,7 @@ impl Binary { /// See [`Binary::executable`] for the platform-specific executable name. pub fn name(&self) -> &'static str { match self { - Binary::Ruff => "ruff", + Self::Ruff => "ruff", } } @@ -56,7 +56,7 @@ impl Binary { format: ArchiveFormat, ) -> Result { match self { - Binary::Ruff => { + Self::Ruff => { let url = format!( "https://github.com/astral-sh/ruff/releases/download/{version}/ruff-{platform}.{}", format.extension() @@ -98,8 +98,8 @@ impl ArchiveFormat { impl From for SourceDistExtension { fn from(val: ArchiveFormat) -> Self { match val { - ArchiveFormat::Zip => SourceDistExtension::Zip, - ArchiveFormat::TarGz => SourceDistExtension::TarGz, + ArchiveFormat::Zip => Self::Zip, + ArchiveFormat::TarGz => Self::TarGz, } } } @@ -148,7 +148,7 @@ 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 Error::NetworkErrorWithRetries { retries, .. } = self { + if let Self::NetworkErrorWithRetries { retries, .. } = self { return retries + 1; } 1 @@ -262,10 +262,8 @@ async fn bin_install_inner( cache: &Cache, reporter: &dyn Reporter, ) -> Result { - let os = Os::from_env(); - let arch = Arch::from_env(); - let libc = Libc::from_env()?; - let platform_name = cargo_dist_platform(os, arch, libc); + let platform = Platform::from_env()?; + let platform_name = platform.as_cargo_dist_triple(); // Check the cache first let cache_entry = CacheEntry::new( @@ -281,7 +279,7 @@ async fn bin_install_inner( return Ok(cache_entry.into_path_buf()); } - let format = if os.is_windows() { + let format = if platform.os.is_windows() { ArchiveFormat::Zip } else { ArchiveFormat::TarGz @@ -366,45 +364,3 @@ async fn bin_install_inner( Ok(cache_entry.into_path_buf()) } - -/// Cast platform types to the binary target triple format used by cargo-dist. -/// -/// This performs some normalization to match cargo-dist's styling. -fn cargo_dist_platform(os: Os, arch: Arch, libc: Libc) -> String { - use target_lexicon::{ - Architecture, ArmArchitecture, OperatingSystem, Riscv64Architecture, X86_32Architecture, - }; - 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() - ) -} diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index 3fba0493a76c9..fe5b5061fa258 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/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 6f71350a20167..8809906cf6c94 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -998,7 +998,7 @@ impl TestContext { /// Create a `uv format` command with options shared across scenarios. pub fn format(&self) -> Command { - let mut command = self.new_command(); + let mut command = Self::new_command(); command.arg("format"); self.add_shared_options(&mut command, false); command diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f5257e2f2ed1e..fd12b8e0b05b6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -21,7 +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 files

+
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

@@ -1840,13 +1840,13 @@ interpreter. Use --universal to display the tree for all platforms, ## uv format -Format Python files. +Format Python code in the project. -Formats Python files using the Ruff formatter. By default, all Python files in the project are formatted. +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`. -Additional arguments can be passed to Ruff after `--`. +By default, Additional arguments can be passed to Ruff after `--`.

Usage

From 1f9756658a47467f3b910a29bab3c956915f4c5a Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 19 Aug 2025 17:37:45 -0500 Subject: [PATCH 16/19] Update help snaps --- crates/uv/tests/it/help.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 3f2a796848e32..5231f70d59cb9 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -25,7 +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 files + 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 @@ -106,7 +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 files + 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 @@ -185,7 +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 files + 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 @@ -968,7 +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 files + 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 @@ -1090,7 +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 files + 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 From 03ecb967670d04a281ae5dac3d105de14bcae425 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 19 Aug 2025 17:38:45 -0500 Subject: [PATCH 17/19] Update preview feature list --- docs/concepts/preview.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/concepts/preview.md b/docs/concepts/preview.md index 1789b5d65e37a..986f7674bd023 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 From 52d9f94a7fd0d32df445aa462a537edbb09931af Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 20 Aug 2025 09:35:32 -0500 Subject: [PATCH 18/19] Cleanup --- Cargo.lock | 1 - crates/uv-bin-install/Cargo.toml | 1 - crates/uv-bin-install/src/lib.rs | 224 +++++++++++++++++++------------ 3 files changed, 137 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6de25bce7a390..d6e72fbfd35c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5173,7 +5173,6 @@ dependencies = [ "uv-client", "uv-distribution-filename", "uv-extract", - "uv-fs", "uv-pep440", "uv-platform", ] diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml index 57fd0ac52366a..6de52683aa6b0 100644 --- a/crates/uv-bin-install/Cargo.toml +++ b/crates/uv-bin-install/Cargo.toml @@ -21,7 +21,6 @@ uv-cache = { workspace = true } uv-client = { workspace = true } uv-distribution-filename = { workspace = true } uv-extract = { workspace = true } -uv-fs = { workspace = true } uv-pep440 = { workspace = true } uv-platform = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } diff --git a/crates/uv-bin-install/src/lib.rs b/crates/uv-bin-install/src/lib.rs index e3d2a597fbd5f..2af9ba848aab6 100644 --- a/crates/uv-bin-install/src/lib.rs +++ b/crates/uv-bin-install/src/lib.rs @@ -155,59 +155,83 @@ impl Error { } } -/// 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); -} +/// 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(); -/// An asynchronous reader that reports progress as bytes are read. -struct ProgressReader<'a, R> { - reader: R, - index: usize, - reporter: &'a dyn Reporter, -} + // 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(), + ); -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, - } + if cache_entry.path().exists() { + return Ok(cache_entry.into_path_buf()); } -} -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); - }) + 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?; + + #[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) } -/// Install a binary for the given tool with retry on failure. -pub async fn bin_install( +/// 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; @@ -215,7 +239,18 @@ pub async fn bin_install( let retry_policy = client.retry_policy(); loop { - let result = bin_install_inner(binary, version, client, cache, reporter).await; + 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), @@ -254,42 +289,20 @@ pub async fn bin_install( } } -/// Install a binary for the given tool (internal implementation without retry). -async fn bin_install_inner( +/// 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 { - 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?; - // Create a temporary directory for extraction let temp_dir = tempfile::tempdir_in(cache.bucket(CacheBucket::Binaries))?; @@ -344,23 +357,60 @@ async fn bin_install_inner( } }; - uv_fs::rename_with_retry(&extracted_binary, cache_entry.path()).await?; + if !extracted_binary.exists() { + return Err(Error::BinaryNotFound { + expected: extracted_binary, + }); + } - #[cfg(unix)] - { - use std::fs::Permissions; - use std::os::unix::fs::PermissionsExt; - let permissions = fs_err::tokio::metadata(cache_entry.path()) - .await? - .permissions(); - if permissions.mode() & 0o111 != 0o111 { - fs_err::tokio::set_permissions( - cache_entry.path(), - Permissions::from_mode(permissions.mode() | 0o111), - ) - .await?; + // 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, } } +} - Ok(cache_entry.into_path_buf()) +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); + }) + } } From 3afc983433155f0833b8e04a0bdae94b360ecba3 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 20 Aug 2025 15:47:34 -0500 Subject: [PATCH 19/19] Propagate inner retry counts --- crates/uv-bin-install/src/lib.rs | 39 ++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/crates/uv-bin-install/src/lib.rs b/crates/uv-bin-install/src/lib.rs index 2af9ba848aab6..058c6f652e97c 100644 --- a/crates/uv-bin-install/src/lib.rs +++ b/crates/uv-bin-install/src/lib.rs @@ -136,8 +136,8 @@ pub enum Error { #[error("Failed to detect platform")] Platform(#[from] uv_platform::Error), - #[error("Request failed after {retries} retries")] - NetworkErrorWithRetries { + #[error("Attempt failed after {retries} retries")] + RetriedError { #[source] err: Box, retries: u32, @@ -148,7 +148,7 @@ 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::NetworkErrorWithRetries { retries, .. } = self { + if let Self::RetriedError { retries, .. } = self { return retries + 1; } 1 @@ -204,6 +204,7 @@ pub async fn bin_install( ) .await?; + // Add executable bit #[cfg(unix)] { use std::fs::Permissions; @@ -256,10 +257,10 @@ async fn download_and_unpack_with_retry( Ok(path) => Ok(path), Err(err) => { total_attempts += err.attempts(); - let n_past_retries = total_attempts - 1; + let past_retries = total_attempts - 1; if is_extended_transient_error(&err) { - let retry_decision = retry_policy.should_retry(start_time, n_past_retries); + 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...", @@ -271,14 +272,14 @@ async fn download_and_unpack_with_retry( .unwrap_or_else(|_| Duration::default()); tokio::time::sleep(duration).await; retried_here = true; - continue; // Retry + continue; } } if retried_here { - Err(Error::NetworkErrorWithRetries { + Err(Error::RetriedError { err: Box::new(err), - retries: n_past_retries, + retries: past_retries, }) } else { Err(err) @@ -316,10 +317,24 @@ async fn download_and_unpack( source: err, })?; - let response = response.error_for_status().map_err(|err| Error::Download { - url: download_url.clone(), - source: reqwest_middleware::Error::Reqwest(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