Add an experimental uv format command (#15017)

As a frontend to Ruff's formatter.

There are some interesting choices here, some of which may just be
temporary:

1. We pin a default version of Ruff, so `uv format` is stable for a
given uv version
2. We install Ruff from GitHub instead of PyPI, which means we don't
need a Python interpreter or environment
3. We do not read the Ruff version from the dependency tree

See https://github.com/astral-sh/ruff/pull/19665 for a prototype of the
LSP integration.
This commit is contained in:
Zanie Blue 2025-08-21 06:33:18 -05:00 committed by GitHub
parent 8d6ea3f2ea
commit e31f000da7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1110 additions and 5 deletions

24
Cargo.lock generated
View file

@ -5043,6 +5043,7 @@ dependencies = [
"unicode-width 0.2.1",
"url",
"uv-auth",
"uv-bin-install",
"uv-build-backend",
"uv-build-frontend",
"uv-cache",
@ -5154,6 +5155,29 @@ dependencies = [
"uv-workspace",
]
[[package]]
name = "uv-bin-install"
version = "0.0.1"
dependencies = [
"fs-err",
"futures",
"reqwest",
"reqwest-middleware",
"reqwest-retry",
"tempfile",
"thiserror 2.0.12",
"tokio",
"tokio-util",
"tracing",
"url",
"uv-cache",
"uv-client",
"uv-distribution-filename",
"uv-extract",
"uv-pep440",
"uv-platform",
]
[[package]]
name = "uv-build"
version = "0.8.12"

View file

@ -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" }

View file

@ -0,0 +1,37 @@
[package]
name = "uv-bin-install"
version = "0.0.1"
edition = { workspace = true }
rust-version = { workspace = true }
homepage = { workspace = true }
documentation = { workspace = true }
repository = { workspace = true }
authors = { workspace = true }
license = { workspace = true }
description = "Binary download and installation utilities for uv"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
uv-cache = { workspace = true }
uv-client = { workspace = true }
uv-distribution-filename = { workspace = true }
uv-extract = { workspace = true }
uv-pep440 = { workspace = true }
uv-platform = { workspace = true }
fs-err = { workspace = true, features = ["tokio"] }
futures = { workspace = true }
reqwest = { workspace = true }
reqwest-middleware = { workspace = true }
reqwest-retry = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

View file

@ -0,0 +1,431 @@
//! Binary download and installation utilities for uv.
//!
//! These utilities are specifically for consuming distributions that are _not_ Python packages,
//! e.g., `ruff` (which does have a Python package, but also has standalone binaries on GitHub).
use std::path::PathBuf;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, SystemTime};
use futures::TryStreamExt;
use reqwest_retry::RetryPolicy;
use std::fmt;
use thiserror::Error;
use tokio::io::{AsyncRead, ReadBuf};
use tokio_util::compat::FuturesAsyncReadCompatExt;
use tracing::debug;
use url::Url;
use uv_distribution_filename::SourceDistExtension;
use uv_cache::{Cache, CacheBucket, CacheEntry};
use uv_client::{BaseClient, is_extended_transient_error};
use uv_extract::{Error as ExtractError, stream};
use uv_pep440::Version;
use uv_platform::Platform;
/// Binary tools that can be installed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Binary {
Ruff,
}
impl Binary {
/// Get the default version for this binary.
pub fn default_version(&self) -> Version {
match self {
// TODO(zanieb): Figure out a nice way to automate updating this
Self::Ruff => Version::new([0, 12, 5]),
}
}
/// The name of the binary.
///
/// See [`Binary::executable`] for the platform-specific executable name.
pub fn name(&self) -> &'static str {
match self {
Self::Ruff => "ruff",
}
}
/// Get the download URL for a specific version and platform.
pub fn download_url(
&self,
version: &Version,
platform: &str,
format: ArchiveFormat,
) -> Result<Url, Error> {
match self {
Self::Ruff => {
let url = format!(
"https://github.com/astral-sh/ruff/releases/download/{version}/ruff-{platform}.{}",
format.extension()
);
Url::parse(&url).map_err(|err| Error::UrlParse { url, source: err })
}
}
}
/// Get the executable name
pub fn executable(&self) -> String {
format!("{}{}", self.name(), std::env::consts::EXE_SUFFIX)
}
}
impl fmt::Display for Binary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
/// Archive formats for binary downloads.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveFormat {
Zip,
TarGz,
}
impl ArchiveFormat {
/// Get the file extension for this archive format.
pub fn extension(&self) -> &'static str {
match self {
Self::Zip => "zip",
Self::TarGz => "tar.gz",
}
}
}
impl From<ArchiveFormat> for SourceDistExtension {
fn from(val: ArchiveFormat) -> Self {
match val {
ArchiveFormat::Zip => Self::Zip,
ArchiveFormat::TarGz => Self::TarGz,
}
}
}
/// Errors that can occur during binary download and installation.
#[derive(Debug, Error)]
pub enum Error {
#[error("Failed to download from: {url}")]
Download {
url: Url,
#[source]
source: reqwest_middleware::Error,
},
#[error("Failed to parse URL: {url}")]
UrlParse {
url: String,
#[source]
source: url::ParseError,
},
#[error("Failed to extract archive")]
Extract {
#[source]
source: ExtractError,
},
#[error("Binary not found in archive at expected location: {expected}")]
BinaryNotFound { expected: PathBuf },
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("Failed to detect platform")]
Platform(#[from] uv_platform::Error),
#[error("Attempt failed after {retries} retries")]
RetriedError {
#[source]
err: Box<Error>,
retries: u32,
},
}
impl Error {
/// Return the number of attempts that were made to complete this request before this error was
/// returned. Note that e.g. 3 retries equates to 4 attempts.
fn attempts(&self) -> u32 {
if let Self::RetriedError { retries, .. } = self {
return retries + 1;
}
1
}
}
/// Install the given binary.
pub async fn bin_install(
binary: Binary,
version: &Version,
client: &BaseClient,
cache: &Cache,
reporter: &dyn Reporter,
) -> Result<PathBuf, Error> {
let platform = Platform::from_env()?;
let platform_name = platform.as_cargo_dist_triple();
// Check the cache first
let cache_entry = CacheEntry::new(
cache
.bucket(CacheBucket::Binaries)
.join(binary.name())
.join(version.to_string())
.join(&platform_name),
binary.executable(),
);
if cache_entry.path().exists() {
return Ok(cache_entry.into_path_buf());
}
let format = if platform.os.is_windows() {
ArchiveFormat::Zip
} else {
ArchiveFormat::TarGz
};
let download_url = binary.download_url(version, &platform_name, format)?;
let cache_dir = cache_entry.dir();
fs_err::tokio::create_dir_all(&cache_dir).await?;
let path = download_and_unpack_with_retry(
binary,
version,
client,
cache,
reporter,
&platform_name,
format,
&download_url,
&cache_entry,
)
.await?;
// Add executable bit
#[cfg(unix)]
{
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let permissions = fs_err::tokio::metadata(&path).await?.permissions();
if permissions.mode() & 0o111 != 0o111 {
fs_err::tokio::set_permissions(
&path,
Permissions::from_mode(permissions.mode() | 0o111),
)
.await?;
}
}
Ok(path)
}
/// Download and unpack a binary with retry on stream failures.
async fn download_and_unpack_with_retry(
binary: Binary,
version: &Version,
client: &BaseClient,
cache: &Cache,
reporter: &dyn Reporter,
platform_name: &str,
format: ArchiveFormat,
download_url: &Url,
cache_entry: &CacheEntry,
) -> Result<PathBuf, Error> {
let mut total_attempts = 0;
let mut retried_here = false;
let start_time = SystemTime::now();
let retry_policy = client.retry_policy();
loop {
let result = download_and_unpack(
binary,
version,
client,
cache,
reporter,
platform_name,
format,
download_url,
cache_entry,
)
.await;
let result = match result {
Ok(path) => Ok(path),
Err(err) => {
total_attempts += err.attempts();
let past_retries = total_attempts - 1;
if is_extended_transient_error(&err) {
let retry_decision = retry_policy.should_retry(start_time, past_retries);
if let reqwest_retry::RetryDecision::Retry { execute_after } = retry_decision {
debug!(
"Transient failure while installing {} {}; retrying...",
binary.name(),
version
);
let duration = execute_after
.duration_since(SystemTime::now())
.unwrap_or_else(|_| Duration::default());
tokio::time::sleep(duration).await;
retried_here = true;
continue;
}
}
if retried_here {
Err(Error::RetriedError {
err: Box::new(err),
retries: past_retries,
})
} else {
Err(err)
}
}
};
return result;
}
}
/// Download and unpackage a binary,
///
/// NOTE [`download_and_unpack_with_retry`] should be used instead.
async fn download_and_unpack(
binary: Binary,
version: &Version,
client: &BaseClient,
cache: &Cache,
reporter: &dyn Reporter,
platform_name: &str,
format: ArchiveFormat,
download_url: &Url,
cache_entry: &CacheEntry,
) -> Result<PathBuf, Error> {
// Create a temporary directory for extraction
let temp_dir = tempfile::tempdir_in(cache.bucket(CacheBucket::Binaries))?;
let response = client
.for_host(&download_url.clone().into())
.get(download_url.clone())
.send()
.await
.map_err(|err| Error::Download {
url: download_url.clone(),
source: err,
})?;
let inner_retries = response
.extensions()
.get::<reqwest_retry::RetryCount>()
.map(|retries| retries.value());
if let Err(status_error) = response.error_for_status_ref() {
let err = Error::Download {
url: download_url.clone(),
source: reqwest_middleware::Error::from(status_error),
};
if let Some(retries) = inner_retries {
return Err(Error::RetriedError {
err: Box::new(err),
retries,
});
}
return Err(err);
}
// Get the download size from headers if available
let size = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|val| val.to_str().ok())
.and_then(|val| val.parse::<u64>().ok());
// Stream download directly to extraction
let reader = response
.bytes_stream()
.map_err(std::io::Error::other)
.into_async_read()
.compat();
let id = reporter.on_download_start(binary.name(), version, size);
let mut progress_reader = ProgressReader::new(reader, id, reporter);
stream::archive(&mut progress_reader, format.into(), temp_dir.path())
.await
.map_err(|e| Error::Extract { source: e })?;
reporter.on_download_complete(id);
// Find the binary in the extracted files
let extracted_binary = match format {
ArchiveFormat::Zip => {
// Windows ZIP archives contain the binary directly in the root
temp_dir.path().join(binary.executable())
}
ArchiveFormat::TarGz => {
// tar.gz archives contain the binary in a subdirectory
temp_dir
.path()
.join(format!("{}-{platform_name}", binary.name()))
.join(binary.executable())
}
};
if !extracted_binary.exists() {
return Err(Error::BinaryNotFound {
expected: extracted_binary,
});
}
// Move the binary to its final location before the temp directory is dropped
fs_err::tokio::rename(&extracted_binary, cache_entry.path()).await?;
Ok(cache_entry.path().to_path_buf())
}
/// Progress reporter for binary downloads.
pub trait Reporter: Send + Sync {
/// Called when a download starts.
fn on_download_start(&self, name: &str, version: &Version, size: Option<u64>) -> 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<R> AsyncRead for ProgressReader<'_, R>
where
R: AsyncRead + Unpin,
{
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
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);
})
}
}

View file

@ -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()

View file

@ -1006,6 +1006,21 @@ pub enum ProjectCommand {
Export(ExportArgs),
/// Display the project's dependency tree.
Tree(TreeArgs),
/// Format Python code in the project.
///
/// Formats Python code using the Ruff formatter. By default, all Python files in the project
/// are formatted. This command has the same behavior as running `ruff format` in the project
/// root.
///
/// To check if files are formatted without modifying them, use `--check`. To see a diff of
/// formatting changes, use `--diff`.
///
/// By default, Additional arguments can be passed to Ruff after `--`.
#[command(
after_help = "Use `uv help format` for more details.",
after_long_help = ""
)]
Format(FormatArgs),
}
/// A re-implementation of `Option`, used to avoid Clap's automatic `Option` flattening in
@ -4281,6 +4296,32 @@ pub struct ExportArgs {
pub python: Option<Maybe<String>>,
}
#[derive(Args)]
pub struct FormatArgs {
/// Check if files are formatted without applying changes.
#[arg(long)]
pub check: bool,
/// Show a diff of formatting changes without applying them.
///
/// Implies `--check`.
#[arg(long)]
pub diff: bool,
/// The version of Ruff to use for formatting.
///
/// By default, a version of Ruff pinned by uv will be used.
#[arg(long)]
pub version: Option<String>,
/// Additional arguments to pass to Ruff.
///
/// For example, use `uv format -- --line-length 100` to set the line length or
/// `uv format -- src/module/foo.py` to format a specific file.
#[arg(last = true)]
pub extra_args: Vec<String>,
}
#[derive(Args)]
pub struct ToolNamespace {
#[command(subcommand)]

View file

@ -17,6 +17,7 @@ bitflags::bitflags! {
const PACKAGE_CONFLICTS = 1 << 5;
const EXTRA_BUILD_DEPENDENCIES = 1 << 6;
const DETECT_MODULE_CONFLICTS = 1 << 7;
const FORMAT = 1 << 8;
}
}
@ -34,6 +35,7 @@ impl PreviewFeatures {
Self::PACKAGE_CONFLICTS => "package-conflicts",
Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies",
Self::DETECT_MODULE_CONFLICTS => "detect-module-conflicts",
Self::FORMAT => "format",
_ => panic!("`flag_as_str` can only be used for exactly one feature flag"),
}
}
@ -79,6 +81,7 @@ impl FromStr for PreviewFeatures {
"package-conflicts" => Self::PACKAGE_CONFLICTS,
"extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES,
"detect-module-conflicts" => Self::DETECT_MODULE_CONFLICTS,
"format" => Self::FORMAT,
_ => {
warn_user_once!("Unknown preview feature: `{part}`");
continue;
@ -253,6 +256,7 @@ mod tests {
PreviewFeatures::DETECT_MODULE_CONFLICTS.flag_as_str(),
"detect-module-conflicts"
);
assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format");
}
#[test]

View file

@ -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 {

View file

@ -15,6 +15,7 @@ workspace = true
[dependencies]
uv-auth = { workspace = true }
uv-bin-install = { workspace = true }
uv-build-backend = { workspace = true }
uv-build-frontend = { workspace = true }
uv-cache = { workspace = true }
@ -90,6 +91,7 @@ rkyv = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tar = { workspace = true }
tempfile = { workspace = true }
textwrap = { workspace = true }
thiserror = { workspace = true }

View file

@ -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;

View file

@ -0,0 +1,70 @@
use std::str::FromStr;
use anyhow::{Context, Result};
use tokio::process::Command;
use uv_bin_install::{Binary, bin_install};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::{Preview, PreviewFeatures};
use uv_pep440::Version;
use uv_warnings::warn_user;
use crate::child::run_to_completion;
use crate::commands::ExitStatus;
use crate::commands::reporters::BinaryDownloadReporter;
use crate::printer::Printer;
use crate::settings::NetworkSettings;
/// Run the formatter.
pub(crate) async fn format(
check: bool,
diff: bool,
extra_args: Vec<String>,
version: Option<String>,
network_settings: NetworkSettings,
cache: Cache,
printer: Printer,
preview: Preview,
) -> Result<ExitStatus> {
// Check if the format feature is in preview
if !preview.is_enabled(PreviewFeatures::FORMAT) {
warn_user!(
"`uv format` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.",
PreviewFeatures::FORMAT
);
}
// Parse version if provided
let version = version.as_deref().map(Version::from_str).transpose()?;
let client = BaseClientBuilder::new()
.retries_from_env()?
.connectivity(network_settings.connectivity)
.native_tls(network_settings.native_tls)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.build();
// Get the path to Ruff, downloading it if necessary
let reporter = BinaryDownloadReporter::single(printer);
let default_version = Binary::Ruff.default_version();
let version = version.as_ref().unwrap_or(&default_version);
let ruff_path = bin_install(Binary::Ruff, version, &client, &cache, &reporter)
.await
.context("Failed to install ruff {version}")?;
let mut command = Command::new(&ruff_path);
command.arg("format");
if check {
command.arg("--check");
}
if diff {
command.arg("--diff");
}
// Add any additional arguments passed after `--`
command.args(extra_args.iter());
let handle = command.spawn().context("Failed to spawn `ruff format`")?;
run_to_completion(handle).await
}

View file

@ -63,6 +63,7 @@ use crate::settings::{
pub(crate) mod add;
pub(crate) mod environment;
pub(crate) mod export;
pub(crate) mod format;
pub(crate) mod init;
mod install_target;
pub(crate) mod lock;

View file

@ -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<u64>) -> 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);
}
}

View file

@ -2187,6 +2187,26 @@ async fn run_project(
.boxed_local()
.await
}
ProjectCommand::Format(args) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::FormatSettings::resolve(args, filesystem);
show_settings!(args);
// Initialize the cache.
let cache = cache.init()?;
Box::pin(commands::format(
args.check,
args.diff,
args.extra_args,
args.version,
globals.network_settings,
cache,
printer,
globals.preview,
))
.await
}
}
}

View file

@ -15,8 +15,8 @@ use uv_cli::{
ToolUninstallArgs, TreeArgs, VenvArgs, VersionArgs, VersionBump, VersionFormat,
};
use uv_cli::{
AuthorFrom, BuildArgs, ExportArgs, PublishArgs, PythonDirArgs, ResolverInstallerArgs,
ToolUpgradeArgs,
AuthorFrom, BuildArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs,
ResolverInstallerArgs, ToolUpgradeArgs,
options::{flag, resolver_installer_options, resolver_options},
};
use uv_client::Connectivity;
@ -1873,6 +1873,34 @@ impl ExportSettings {
}
}
/// The resolved settings to use for a `format` invocation.
#[derive(Debug, Clone)]
pub(crate) struct FormatSettings {
pub(crate) check: bool,
pub(crate) diff: bool,
pub(crate) extra_args: Vec<String>,
pub(crate) version: Option<String>,
}
impl FormatSettings {
/// Resolve the [`FormatSettings`] from the CLI and filesystem configuration.
pub(crate) fn resolve(args: FormatArgs, _filesystem: Option<FilesystemOptions>) -> Self {
let FormatArgs {
check,
diff,
extra_args,
version,
} = args;
Self {
check,
diff,
extra_args,
version,
}
}
}
/// The resolved settings to use for a `pip compile` invocation.
#[derive(Debug, Clone)]
pub(crate) struct PipCompileSettings {

View file

@ -996,6 +996,14 @@ impl TestContext {
command
}
/// Create a `uv format` command with options shared across scenarios.
pub fn format(&self) -> Command {
let mut command = Self::new_command();
command.arg("format");
self.add_shared_options(&mut command, false);
command
}
/// Create a `uv build` command with options shared across scenarios.
pub fn build(&self) -> Command {
let mut command = Self::new_command();

View file

@ -0,0 +1,273 @@
use anyhow::Result;
use assert_fs::prelude::*;
use indoc::indoc;
use insta::assert_snapshot;
use crate::common::{TestContext, uv_snapshot};
#[test]
fn format_project() -> Result<()> {
let context = TestContext::new_with_versions(&[]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
// Create an unformatted Python file
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r#"
import sys
def hello():
print( "Hello, World!" )
if __name__=="__main__":
hello( )
"#})?;
uv_snapshot!(context.filters(), context.format(), @r"
success: true
exit_code: 0
----- stdout -----
1 file reformatted
----- stderr -----
warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning.
");
// Check that the file was formatted
let formatted_content = fs_err::read_to_string(&main_py)?;
assert_snapshot!(formatted_content, @r#"
import sys
def hello():
print("Hello, World!")
if __name__ == "__main__":
hello()
"#);
Ok(())
}
#[test]
fn format_check() -> Result<()> {
let context = TestContext::new_with_versions(&[]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
// Create an unformatted Python file
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r#"
def hello():
print( "Hello, World!" )
"#})?;
uv_snapshot!(context.filters(), context.format().arg("--check"), @r"
success: false
exit_code: 1
----- stdout -----
Would reformat: main.py
1 file would be reformatted
----- stderr -----
warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning.
");
// Verify the file wasn't modified
let content = fs_err::read_to_string(&main_py)?;
assert_snapshot!(content, @r#"
def hello():
print( "Hello, World!" )
"#);
Ok(())
}
#[test]
fn format_diff() -> Result<()> {
let context = TestContext::new_with_versions(&[]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
// Create an unformatted Python file
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r#"
def hello():
print( "Hello, World!" )
"#})?;
uv_snapshot!(context.filters(), context.format().arg("--diff"), @r#"
success: false
exit_code: 1
----- stdout -----
--- main.py
+++ main.py
@@ -1,2 +1,2 @@
-def hello():
- print( "Hello, World!" )
+def hello():
+ print("Hello, World!")
----- stderr -----
warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning.
1 file would be reformatted
"#);
// Verify the file wasn't modified
let content = fs_err::read_to_string(&main_py)?;
assert_snapshot!(content, @r#"
def hello():
print( "Hello, World!" )
"#);
Ok(())
}
#[test]
fn format_with_ruff_args() -> Result<()> {
let context = TestContext::new_with_versions(&[]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
// Create a Python file with a long line
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r#"
def hello():
print("This is a very long line that should normally be wrapped by the formatter but we will configure it to have a longer line length")
"#})?;
// Run format with custom line length
uv_snapshot!(context.filters(), context.format().arg("--").arg("main.py").arg("--line-length").arg("200"), @r"
success: true
exit_code: 0
----- stdout -----
1 file left unchanged
----- stderr -----
warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning.
");
// Check that the line wasn't wrapped (since we set a long line length)
let formatted_content = fs_err::read_to_string(&main_py)?;
assert_snapshot!(formatted_content, @r#"
def hello():
print("This is a very long line that should normally be wrapped by the formatter but we will configure it to have a longer line length")
"#);
Ok(())
}
#[test]
fn format_specific_files() -> Result<()> {
let context = TestContext::new_with_versions(&[]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
// Create multiple unformatted Python files
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r#"
def main():
print( "Main" )
"#})?;
let utils_py = context.temp_dir.child("utils.py");
utils_py.write_str(indoc! {r#"
def utils():
print( "utils" )
"#})?;
uv_snapshot!(context.filters(), context.format().arg("--").arg("main.py"), @r"
success: true
exit_code: 0
----- stdout -----
1 file reformatted
----- stderr -----
warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning.
");
let main_content = fs_err::read_to_string(&main_py)?;
assert_snapshot!(main_content, @r#"
def main():
print("Main")
"#);
// Unchanged
let utils_content = fs_err::read_to_string(&utils_py)?;
assert_snapshot!(utils_content, @r#"
def utils():
print( "utils" )
"#);
Ok(())
}
#[test]
fn format_version_option() -> Result<()> {
let context = TestContext::new_with_versions(&[]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []
"#})?;
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r"
def hello(): pass
"})?;
// Run format with specific Ruff version
// TODO(zanieb): It'd be nice to assert on the version used here somehow? Maybe we should emit
// the version we're using to stderr? Alas there's not a way to get the Ruff version from the
// format command :)
uv_snapshot!(context.filters(), context.format().arg("--version").arg("0.8.2"), @r"
success: true
exit_code: 0
----- stdout -----
1 file reformatted
----- stderr -----
warning: `uv format` is experimental and may change without warning. Pass `--preview-features format` to disable this warning.
");
Ok(())
}

View file

@ -25,6 +25,7 @@ fn help() {
lock Update the project's lockfile
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
tool Run and install commands provided by Python packages
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
@ -105,6 +106,7 @@ fn help_flag() {
lock Update the project's lockfile
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
tool Run and install commands provided by Python packages
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
@ -183,6 +185,7 @@ fn help_short_flag() {
lock Update the project's lockfile
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
tool Run and install commands provided by Python packages
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
@ -880,6 +883,7 @@ fn help_unknown_subcommand() {
lock
export
tree
format
tool
python
pip
@ -907,6 +911,7 @@ fn help_unknown_subcommand() {
lock
export
tree
format
tool
python
pip
@ -963,6 +968,7 @@ fn help_with_global_option() {
lock Update the project's lockfile
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
tool Run and install commands provided by Python packages
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface
@ -1084,6 +1090,7 @@ fn help_with_no_pager() {
lock Update the project's lockfile
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
tool Run and install commands provided by Python packages
python Manage Python versions and installations
pip Manage Python packages with a pip-compatible interface

View file

@ -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"))]

View file

@ -7720,7 +7720,7 @@ fn preview_features() {
show_settings: true,
preview: Preview {
flags: PreviewFeatures(
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS,
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT,
),
},
python_preference: Managed,
@ -7946,7 +7946,7 @@ fn preview_features() {
show_settings: true,
preview: Preview {
flags: PreviewFeatures(
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS,
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT,
),
},
python_preference: Managed,

View file

@ -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

View file

@ -21,6 +21,7 @@ uv [OPTIONS] <COMMAND>
<dt><a href="#uv-lock"><code>uv lock</code></a></dt><dd><p>Update the project's lockfile</p></dd>
<dt><a href="#uv-export"><code>uv export</code></a></dt><dd><p>Export the project's lockfile to an alternate format</p></dd>
<dt><a href="#uv-tree"><code>uv tree</code></a></dt><dd><p>Display the project's dependency tree</p></dd>
<dt><a href="#uv-format"><code>uv format</code></a></dt><dd><p>Format Python code in the project</p></dd>
<dt><a href="#uv-tool"><code>uv tool</code></a></dt><dd><p>Run and install commands provided by Python packages</p></dd>
<dt><a href="#uv-python"><code>uv python</code></a></dt><dd><p>Manage Python versions and installations</p></dd>
<dt><a href="#uv-pip"><code>uv pip</code></a></dt><dd><p>Manage Python packages with a pip-compatible interface</p></dd>
@ -1841,6 +1842,81 @@ interpreter. Use <code>--universal</code> to display the tree for all platforms,
<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (<a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives">https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives</a>)</p>
</dd></dl>
## uv format
Format Python code in the project.
Formats Python code using the Ruff formatter. By default, all Python files in the project are formatted. This command has the same behavior as running `ruff format` in the project root.
To check if files are formatted without modifying them, use `--check`. To see a diff of formatting changes, use `--diff`.
By default, Additional arguments can be passed to Ruff after `--`.
<h3 class="cli-reference">Usage</h3>
```
uv format [OPTIONS] [-- <EXTRA_ARGS>...]
```
<h3 class="cli-reference">Arguments</h3>
<dl class="cli-reference"><dt id="uv-format--extra_args"><a href="#uv-format--extra_args"<code>EXTRA_ARGS</code></a></dt><dd><p>Additional arguments to pass to Ruff.</p>
<p>For example, use <code>uv format -- --line-length 100</code> to set the line length or <code>uv format -- src/module/foo.py</code> to format a specific file.</p>
</dd></dl>
<h3 class="cli-reference">Options</h3>
<dl class="cli-reference"><dt id="uv-format--allow-insecure-host"><a href="#uv-format--allow-insecure-host"><code>--allow-insecure-host</code></a>, <code>--trusted-host</code> <i>allow-insecure-host</i></dt><dd><p>Allow insecure connections to a host.</p>
<p>Can be provided multiple times.</p>
<p>Expects to receive either a hostname (e.g., <code>localhost</code>), a host-port pair (e.g., <code>localhost:8080</code>), or a URL (e.g., <code>https://localhost</code>).</p>
<p>WARNING: Hosts included in this list will not be verified against the system's certificate store. Only use <code>--allow-insecure-host</code> in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.</p>
<p>May also be set with the <code>UV_INSECURE_HOST</code> environment variable.</p></dd><dt id="uv-format--cache-dir"><a href="#uv-format--cache-dir"><code>--cache-dir</code></a> <i>cache-dir</i></dt><dd><p>Path to the cache directory.</p>
<p>Defaults to <code>$XDG_CACHE_HOME/uv</code> or <code>$HOME/.cache/uv</code> on macOS and Linux, and <code>%LOCALAPPDATA%\uv\cache</code> on Windows.</p>
<p>To view the location of the cache directory, run <code>uv cache dir</code>.</p>
<p>May also be set with the <code>UV_CACHE_DIR</code> environment variable.</p></dd><dt id="uv-format--check"><a href="#uv-format--check"><code>--check</code></a></dt><dd><p>Check if files are formatted without applying changes</p>
</dd><dt id="uv-format--color"><a href="#uv-format--color"><code>--color</code></a> <i>color-choice</i></dt><dd><p>Control the use of color in output.</p>
<p>By default, uv will automatically detect support for colors when writing to a terminal.</p>
<p>Possible values:</p>
<ul>
<li><code>auto</code>: Enables colored output only when the output is going to a terminal or TTY with support</li>
<li><code>always</code>: Enables colored output regardless of the detected environment</li>
<li><code>never</code>: Disables colored output</li>
</ul></dd><dt id="uv-format--config-file"><a href="#uv-format--config-file"><code>--config-file</code></a> <i>config-file</i></dt><dd><p>The path to a <code>uv.toml</code> file to use for configuration.</p>
<p>While uv configuration can be included in a <code>pyproject.toml</code> file, it is not allowed in this context.</p>
<p>May also be set with the <code>UV_CONFIG_FILE</code> environment variable.</p></dd><dt id="uv-format--diff"><a href="#uv-format--diff"><code>--diff</code></a></dt><dd><p>Show a diff of formatting changes without applying them.</p>
<p>Implies <code>--check</code>.</p>
</dd><dt id="uv-format--directory"><a href="#uv-format--directory"><code>--directory</code></a> <i>directory</i></dt><dd><p>Change to the given directory prior to running the command.</p>
<p>Relative paths are resolved with the given directory as the base.</p>
<p>See <code>--project</code> to only change the project root directory.</p>
</dd><dt id="uv-format--help"><a href="#uv-format--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>
</dd><dt id="uv-format--managed-python"><a href="#uv-format--managed-python"><code>--managed-python</code></a></dt><dd><p>Require use of uv-managed Python versions.</p>
<p>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.</p>
<p>May also be set with the <code>UV_MANAGED_PYTHON</code> environment variable.</p></dd><dt id="uv-format--native-tls"><a href="#uv-format--native-tls"><code>--native-tls</code></a></dt><dd><p>Whether to load TLS certificates from the platform's native certificate store.</p>
<p>By default, uv loads certificates from the bundled <code>webpki-roots</code> crate. The <code>webpki-roots</code> are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).</p>
<p>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.</p>
<p>May also be set with the <code>UV_NATIVE_TLS</code> environment variable.</p></dd><dt id="uv-format--no-cache"><a href="#uv-format--no-cache"><code>--no-cache</code></a>, <code>--no-cache-dir</code>, <code>-n</code></dt><dd><p>Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation</p>
<p>May also be set with the <code>UV_NO_CACHE</code> environment variable.</p></dd><dt id="uv-format--no-config"><a href="#uv-format--no-config"><code>--no-config</code></a></dt><dd><p>Avoid discovering configuration files (<code>pyproject.toml</code>, <code>uv.toml</code>).</p>
<p>Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.</p>
<p>May also be set with the <code>UV_NO_CONFIG</code> environment variable.</p></dd><dt id="uv-format--no-managed-python"><a href="#uv-format--no-managed-python"><code>--no-managed-python</code></a></dt><dd><p>Disable use of uv-managed Python versions.</p>
<p>Instead, uv will search for a suitable Python version on the system.</p>
<p>May also be set with the <code>UV_NO_MANAGED_PYTHON</code> environment variable.</p></dd><dt id="uv-format--no-progress"><a href="#uv-format--no-progress"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>
<p>For example, spinners or progress bars.</p>
<p>May also be set with the <code>UV_NO_PROGRESS</code> environment variable.</p></dd><dt id="uv-format--no-python-downloads"><a href="#uv-format--no-python-downloads"><code>--no-python-downloads</code></a></dt><dd><p>Disable automatic downloads of Python.</p>
</dd><dt id="uv-format--offline"><a href="#uv-format--offline"><code>--offline</code></a></dt><dd><p>Disable network access.</p>
<p>When disabled, uv will only use locally cached data and locally available files.</p>
<p>May also be set with the <code>UV_OFFLINE</code> environment variable.</p></dd><dt id="uv-format--project"><a href="#uv-format--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>All <code>pyproject.toml</code>, <code>uv.toml</code>, and <code>.python-version</code> files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (<code>.venv</code>).</p>
<p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p>
<p>See <code>--directory</code> to change the working directory entirely.</p>
<p>This setting has no effect when used in the <code>uv pip</code> interface.</p>
<p>May also be set with the <code>UV_PROJECT</code> environment variable.</p></dd><dt id="uv-format--quiet"><a href="#uv-format--quiet"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>
<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which uv will write no output to stdout.</p>
</dd><dt id="uv-format--verbose"><a href="#uv-format--verbose"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output.</p>
<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (<a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives">https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives</a>)</p>
</dd><dt id="uv-format--version"><a href="#uv-format--version"><code>--version</code></a> <i>version</i></dt><dd><p>The version of Ruff to use for formatting.</p>
<p>By default, a version of Ruff pinned by uv will be used.</p>
</dd></dl>
## uv tool
Run and install commands provided by Python packages