feature: UV_UPGRADE_RECURSIVE=true or --recursive

This commit is contained in:
Rene Leonhardt 2025-06-10 11:10:07 +02:00
parent f60c3e0ece
commit a654845891
No known key found for this signature in database
GPG key ID: 8C95C84F75AB1E8E
4 changed files with 215 additions and 115 deletions

View file

@ -614,6 +614,10 @@ pub struct UpgradeProjectArgs {
#[arg(long)]
pub dry_run: bool,
/// Search recursively for pyproject.toml files.
#[arg(long, env = EnvVars::UV_UPGRADE_RECURSIVE)]
pub recursive: bool,
/// The Python interpreter to use during resolution (overrides pyproject.toml).
///
/// A Python interpreter is required for building source distributions to determine package

View file

@ -713,6 +713,10 @@ impl EnvVars {
#[attr_hidden]
pub const UV_RUN_MAX_RECURSION_DEPTH: &'static str = "UV_RUN_MAX_RECURSION_DEPTH";
/// Should `uv upgrade` allow recursive search for pyproject.toml files?
#[attr_hidden]
pub const UV_UPGRADE_RECURSIVE: &'static str = "UV_UPGRADE_RECURSIVE";
/// Overrides terminal width used for wrapping. This variable is not read by uv directly.
///
/// This is a quasi-standard variable, described, e.g., in `ncurses(3x)`.

View file

@ -1,4 +1,5 @@
use std::env;
use std::ffi::OsStr;
use std::fmt::Write;
use std::io::ErrorKind;
use std::path::Path;
@ -9,18 +10,19 @@ use owo_colors::OwoColorize;
use prettytable::format::FormatBuilder;
use prettytable::row;
use tokio::sync::Semaphore;
use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_cli::{Maybe, UpgradeProjectArgs};
use uv_client::{BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::Concurrency;
use uv_distribution_filename::DistFilename;
use uv_distribution_types::{IndexCapabilities, IndexLocations};
use uv_pep440::{Version, VersionSpecifiers};
use uv_pep508::{PackageName, Requirement};
use uv_resolver::{PrereleaseMode, RequiresPython};
use uv_warnings::warn_user;
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
use walkdir::WalkDir;
use crate::commands::ExitStatus;
use crate::commands::pip::latest::LatestClient;
@ -30,130 +32,219 @@ use crate::printer::Printer;
///
/// This doesn't read or modify uv.lock, only constraints like `<1.0` are bumped.
pub(crate) async fn upgrade_project_dependencies(args: UpgradeProjectArgs) -> Result<ExitStatus> {
let pyproject_toml = Path::new("pyproject.toml");
let content = match fs_err::tokio::read_to_string(pyproject_toml).await {
Ok(content) => content,
Err(err) => {
if err.kind() == ErrorKind::NotFound {
warn_user!("No pyproject.toml found in current directory");
return Ok(ExitStatus::Error);
}
return Err(err.into());
}
};
let mut toml = match PyProjectTomlMut::from_toml(&content, DependencyTarget::PyProjectToml) {
Ok(toml) => toml,
Err(err) => {
warn_user!("Couldn't read pyproject.toml: {}", err);
return Ok(ExitStatus::Error);
}
};
#[allow(deprecated)]
let cache_dir = env::home_dir().unwrap().join(".cache/uv");
let cache = Cache::from_settings(false, Some(cache_dir))?.init()?;
let capabilities = IndexCapabilities::default();
let client_builder = BaseClientBuilder::new();
// Initialize the registry client.
let client = RegistryClientBuilder::try_from(client_builder)?
.cache(cache.clone().with_refresh(Refresh::All(Timestamp::now())))
.index_locations(&IndexLocations::default())
.build();
let download_concurrency = Semaphore::new(Concurrency::default().downloads);
let python = args.python.and_then(Maybe::into_option).or_else(|| {
toml.get_requires_python()
.map(std::string::ToString::to_string)
});
let version_specifiers = python.and_then(|s| VersionSpecifiers::from_str(&s).ok());
let requires_python = version_specifiers
.map(|v| RequiresPython::from_specifiers(&v))
.unwrap_or_else(|| RequiresPython::greater_than_equal_version(&Version::new([4]))); // allow any by default
// Initialize the client to fetch the latest version of each package.
let client = LatestClient {
client: &client,
capabilities: &capabilities,
prerelease: PrereleaseMode::Disallow,
exclude_newer: None,
tags: None,
requires_python: &requires_python,
};
let find_latest = async |name: String| {
client
.find_latest(
&PackageName::from_str(name.as_str()).unwrap(),
None,
&download_concurrency,
)
.await
.ok()
.flatten()
.map(uv_distribution_filename::DistFilename::into_version)
let tomls = match args
.recursive
.then(|| search_pyproject_tomls(Path::new(".")))
{
None => vec![".".to_string()],
Some(Ok(tomls)) => tomls,
Some(Err(err)) => return Err(err),
};
let printer = Printer::Default;
let mut stderr = printer.stderr();
let info = "info".cyan();
let info = info.bold();
let info = format!("{}{}", "info".cyan().bold(), ":".bold());
let (found, all_upgrades) = toml.upgrade_all_dependencies(&find_latest).await;
if all_upgrades.is_empty() {
if found == 0 {
let mut item_written = false;
let prepend = |written| {
if written {
writeln!(printer.stderr()).expect("");
}
};
let (mut all_found, mut all_bumped) = (0, 0);
for toml_dir in tomls {
prepend(item_written);
item_written = false;
let pyproject_toml = Path::new(&toml_dir).join("pyproject.toml");
let content = match fs_err::tokio::read_to_string(pyproject_toml.clone()).await {
Ok(content) => content,
Err(err) => {
if err.kind() == ErrorKind::NotFound {
warn_user!("No pyproject.toml found in current directory");
return Ok(ExitStatus::Error);
}
return Err(err.into());
}
};
let mut toml = match PyProjectTomlMut::from_toml(&content, DependencyTarget::PyProjectToml)
{
Ok(toml) => toml,
Err(err) => {
warn_user!("Couldn't read pyproject.toml: {}", err);
return Ok(ExitStatus::Error);
}
};
#[allow(deprecated)]
let cache_dir = env::home_dir().unwrap().join(".cache/uv");
let cache = Cache::from_settings(false, Some(cache_dir))?.init()?;
let capabilities = IndexCapabilities::default();
let client_builder = BaseClientBuilder::new();
// Initialize the registry client.
let client = RegistryClientBuilder::try_from(client_builder)?
.cache(cache.clone().with_refresh(Refresh::All(Timestamp::now())))
.index_locations(&IndexLocations::default())
.build();
let download_concurrency = Semaphore::new(Concurrency::default().downloads);
let python = args
.python
.clone()
.and_then(Maybe::into_option)
.or_else(|| {
toml.get_requires_python()
.map(std::string::ToString::to_string)
});
let version_specifiers = python.and_then(|s| VersionSpecifiers::from_str(&s).ok());
let requires_python = version_specifiers
.map(|v| RequiresPython::from_specifiers(&v))
.unwrap_or_else(|| RequiresPython::greater_than_equal_version(&Version::new([4]))); // allow any by default
// Initialize the client to fetch the latest version of each package.
let client = LatestClient {
client: &client,
capabilities: &capabilities,
prerelease: PrereleaseMode::Disallow,
exclude_newer: None,
tags: None,
requires_python: &requires_python,
};
let find_latest = async |name: String| {
client
.find_latest(
&PackageName::from_str(name.as_str()).unwrap(),
None,
&download_concurrency,
)
.await
.ok()
.flatten()
.map(DistFilename::into_version)
};
let relative = if toml_dir == "." {
String::new()
} else {
format!("{}/", &toml_dir[2..])
};
let subpath = format!("{relative}pyproject.toml");
let (found, upgrades) = toml.upgrade_all_dependencies(&find_latest).await;
let bumped = upgrades.len();
all_found += found;
all_bumped += bumped;
if upgrades.is_empty() {
if args.recursive && bumped == 0 {
continue; // Skip intermediate messages if nothing was changed
}
if found == 0 {
writeln!(
printer.stderr(),
"{info} No dependencies found in {subpath}"
)?;
} else {
writeln!(
printer.stderr(),
"{info} No upgrades found in {subpath}, check manually if not committed yet"
)?;
}
continue;
}
let mut table = prettytable::Table::new();
table.set_format(FormatBuilder::new().column_separator(' ').build());
let dry_run = format!(
"upgraded {subpath}{}",
if args.dry_run { " (dry run)" } else { "" }
);
table.add_row(row![r->"#", rb->"name", Fr->"-old", bFg->"+new", "latest", dry_run]); // diff-like
let remove_spaces = |v: &Requirement| {
v.clone()
.version_or_url
.unwrap()
.to_string()
.replace(' ', "")
};
upgrades
.iter()
.enumerate()
.for_each(|(i, (_, _dep, old, new, version, upgraded))| {
let from = remove_spaces(old);
let to = remove_spaces(new);
let upordown = if *upgraded { "✅ up" } else { "❌ down" };
table.add_row(
row![r->i + 1, rb->old.name, Fr->from, bFg->to, version.to_string(), upordown],
);
});
table.printstd();
if !args.dry_run {
if let Err(err) = fs_err::tokio::write(pyproject_toml, toml.to_string()).await {
return Err(err.into());
}
writeln!(
stderr,
"{info}{} No dependencies found in pyproject.toml",
":".bold()
printer.stderr(),
"{info} {subpath} upgraded 🚀 Check manually, update lock + venv {} and run tests",
"`uv sync -U`".green().bold()
)?;
}
item_written = true;
}
if args.recursive && all_bumped == 0 {
if all_found == 0 {
writeln!(printer.stderr(), "{info} No dependencies found recursively")?;
} else {
writeln!(
stderr,
"{info}{} No upgrades found in pyproject.toml, check manually if not committed yet",
":".bold()
printer.stderr(),
"{info} No upgrades found recursively, check manually if not committed yet"
)?;
}
return Ok(ExitStatus::Success);
}
let mut table = prettytable::Table::new();
table.set_format(FormatBuilder::new().column_separator(' ').build());
let dry_run = if args.dry_run {
"upgraded (dry run)"
} else {
"upgraded"
};
table.add_row(row![r->"#", rb->"name", Fr->"-old", bFg->"+new", "latest", dry_run]); // diff-like
let remove_spaces = |v: &Requirement| {
v.clone()
.version_or_url
.unwrap()
.to_string()
.replace(' ', "")
};
all_upgrades
.iter()
.enumerate()
.for_each(|(i, (_, _dep, old, new, version, upgraded))| {
let from = remove_spaces(old);
let to = remove_spaces(new);
let upordown = if *upgraded { "✅ up" } else { "❌ down" };
table.add_row(
row![r->i + 1, rb->old.name, Fr->from, bFg->to, version.to_string(), upordown],
);
});
table.printstd();
if !args.dry_run {
if let Err(err) = fs_err::tokio::write(pyproject_toml, toml.to_string()).await {
return Err(err.into());
}
writeln!(
stderr,
"{info}{} pyproject.toml upgraded 🚀 Check manually then update {} and run tests",
":".bold(),
"`uv lock -U && uv sync`".green().bold()
)?;
}
Ok(ExitStatus::Success)
}
/// Recursively search for pyproject.toml files.
fn search_pyproject_tomls(root: &Path) -> Result<Vec<String>, anyhow::Error> {
let metadata = match fs_err::symlink_metadata(root) {
Ok(metadata) => metadata,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(vec![]),
Err(err) => return Err(anyhow::Error::from(err)),
};
if !metadata.is_dir() {
return Ok(vec![]);
}
// Hint: Doesn't skip special folders like `build`, `dist` or `target`
let is_hidden_or_not_pyproject = |path: &Path| {
path.file_name().and_then(OsStr::to_str).is_some_and(|s| {
s.starts_with('.') || s.starts_with('_') || path.is_file() && s != "pyproject.toml"
})
};
let mut matches: Vec<_> = WalkDir::new(root)
.sort_by_file_name()
.into_iter()
.filter_entry(|entry| {
// TODO(konsti): This should be prettier.
let relative = entry
.path()
.strip_prefix(root)
.expect("walkdir starts with root");
let hidden = is_hidden_or_not_pyproject(relative);
!hidden
})
.filter_map(|entry| {
let path = entry.as_ref().unwrap().path();
if path.is_dir() {
None
} else {
Some(path.parent().unwrap().to_str().unwrap().to_string())
}
})
.collect();
matches.sort();
Ok(matches)
}

View file

@ -996,7 +996,8 @@ metadata when there are not wheels.</p>
<p>See <a href="#uv-python">uv python</a> for details on Python discovery and supported request formats.</p>
<p>May also be set with the <code>UV_PYTHON</code> environment variable.</p></dd><dt id="uv-upgrade--quiet"><a href="#uv-upgrade--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-upgrade--verbose"><a href="#uv-upgrade--verbose"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output.</p>
</dd><dt id="uv-upgrade--recursive"><a href="#uv-upgrade--recursive"><code>--recursive</code></a></dt><dd><p>Search recursively for pyproject.toml files</p>
<p>May also be set with the <code>UV_UPGRADE_RECURSIVE</code> environment variable.</p></dd><dt id="uv-upgrade--verbose"><a href="#uv-upgrade--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></dl>