Skip to content

Commit e604fe8

Browse files
committed
Add uv sync --dry-run
1 parent 5493def commit e604fe8

File tree

11 files changed

+404
-129
lines changed

11 files changed

+404
-129
lines changed

crates/uv-cli/src/lib.rs

+7
Original file line numberDiff line numberDiff line change
@@ -3091,6 +3091,13 @@ pub struct SyncArgs {
30913091
#[arg(long, env = EnvVars::UV_FROZEN, value_parser = clap::builder::BoolishValueParser::new(), conflicts_with = "locked")]
30923092
pub frozen: bool,
30933093

3094+
/// Perform a dry run, without writing the lockfile or modifying the project environment.
3095+
///
3096+
/// In dry-run mode, uv will resolve the project's dependencies and report on the resulting
3097+
/// changes to both the lockfile and the project environment, but will not modify either.
3098+
#[arg(long, conflicts_with = "locked", conflicts_with = "frozen")]
3099+
pub dry_run: bool,
3100+
30943101
#[command(flatten)]
30953102
pub installer: ResolverInstallerArgs,
30963103

crates/uv/src/commands/project/add.rs

+7-4
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ use crate::commands::project::install_target::InstallTarget;
4646
use crate::commands::project::lock::LockMode;
4747
use crate::commands::project::lock_target::LockTarget;
4848
use crate::commands::project::{
49-
init_script_python_requirement, PlatformState, ProjectError, ProjectInterpreter,
50-
ScriptInterpreter, UniversalState,
49+
init_script_python_requirement, PlatformState, ProjectEnvironment, ProjectError,
50+
ProjectInterpreter, ScriptInterpreter, UniversalState,
5151
};
5252
use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
5353
use crate::commands::{diagnostics, project, ExitStatus};
@@ -223,7 +223,7 @@ pub(crate) async fn add(
223223
AddTarget::Project(project, Box::new(PythonTarget::Interpreter(interpreter)))
224224
} else {
225225
// Discover or create the virtual environment.
226-
let venv = project::get_or_init_environment(
226+
let venv = ProjectEnvironment::get_or_init(
227227
project.workspace(),
228228
python.as_deref().map(PythonRequest::parse),
229229
&install_mirrors,
@@ -235,9 +235,11 @@ pub(crate) async fn add(
235235
no_config,
236236
active,
237237
cache,
238+
false,
238239
printer,
239240
)
240-
.await?;
241+
.await?
242+
.into_environment();
241243

242244
AddTarget::Project(project, Box::new(PythonTarget::Environment(venv)))
243245
}
@@ -880,6 +882,7 @@ async fn lock_and_sync(
880882
native_tls,
881883
allow_insecure_host,
882884
cache,
885+
false,
883886
printer,
884887
preview,
885888
)

crates/uv/src/commands/project/lock.rs

+6-2
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,6 @@ pub(super) async fn do_safe_lock(
222222
mode: LockMode<'_>,
223223
target: LockTarget<'_>,
224224
settings: ResolverSettingsRef<'_>,
225-
226225
state: &UniversalState,
227226
logger: Box<dyn ResolveLogger>,
228227
connectivity: Connectivity,
@@ -1122,10 +1121,11 @@ fn report_upgrades(
11221121
.unwrap_or_else(|| "(dynamic)".to_string())
11231122
}
11241123

1125-
updated = true;
11261124
match (existing_packages.get(name), new_distributions.get(name)) {
11271125
(Some(existing_versions), Some(new_versions)) => {
11281126
if existing_versions != new_versions {
1127+
updated = true;
1128+
11291129
let existing_versions = existing_versions
11301130
.iter()
11311131
.map(|version| format_version(*version))
@@ -1144,6 +1144,8 @@ fn report_upgrades(
11441144
}
11451145
}
11461146
(Some(existing_versions), None) => {
1147+
updated = true;
1148+
11471149
let existing_versions = existing_versions
11481150
.iter()
11491151
.map(|version| format_version(*version))
@@ -1156,6 +1158,8 @@ fn report_upgrades(
11561158
)?;
11571159
}
11581160
(None, Some(new_versions)) => {
1161+
updated = true;
1162+
11591163
let new_versions = new_versions
11601164
.iter()
11611165
.map(|version| format_version(*version))

crates/uv/src/commands/project/mod.rs

+153-107
Original file line numberDiff line numberDiff line change
@@ -930,124 +930,170 @@ impl ScriptPython {
930930
}
931931
}
932932

933-
/// Initialize a virtual environment for the current project.
934-
pub(crate) async fn get_or_init_environment(
935-
workspace: &Workspace,
936-
python: Option<PythonRequest>,
937-
install_mirrors: &PythonInstallMirrors,
938-
python_preference: PythonPreference,
939-
python_downloads: PythonDownloads,
940-
connectivity: Connectivity,
941-
native_tls: bool,
942-
allow_insecure_host: &[TrustedHost],
943-
no_config: bool,
944-
active: Option<bool>,
945-
cache: &Cache,
946-
printer: Printer,
947-
) -> Result<PythonEnvironment, ProjectError> {
948-
// Lock the project environment to avoid synchronization issues.
949-
let _lock = ProjectInterpreter::lock(workspace).await?;
933+
/// The Python environment for a project.
934+
#[derive(Debug)]
935+
enum ProjectEnvironment {
936+
/// An existing [`PythonEnvironment`] was discovered, which satisfies the project's requirements.
937+
Existing(PythonEnvironment),
938+
/// An existing [`PythonEnvironment`] was discovered, but did not satisfy the project's
939+
/// requirements, and so was replaced.
940+
///
941+
/// In `--dry-run` mode, the environment will not be replaced, but this variant will still be
942+
/// returned.
943+
Replaced(PythonEnvironment, PathBuf),
944+
/// A new [`PythonEnvironment`] was created.
945+
///
946+
/// In `--dry-run` mode, the environment will not be created, but this variant will still be
947+
/// returned.
948+
New(PythonEnvironment, PathBuf),
949+
}
950950

951-
match ProjectInterpreter::discover(
952-
workspace,
953-
workspace.install_path().as_ref(),
954-
python,
955-
python_preference,
956-
python_downloads,
957-
connectivity,
958-
native_tls,
959-
allow_insecure_host,
960-
install_mirrors,
961-
no_config,
962-
active,
963-
cache,
964-
printer,
965-
)
966-
.await?
967-
{
968-
// If we found an existing, compatible environment, use it.
969-
ProjectInterpreter::Environment(environment) => Ok(environment),
970-
971-
// Otherwise, create a virtual environment with the discovered interpreter.
972-
ProjectInterpreter::Interpreter(interpreter) => {
973-
let venv = workspace.venv(active);
974-
975-
// Avoid removing things that are not virtual environments
976-
let should_remove = match (venv.try_exists(), venv.join("pyvenv.cfg").try_exists()) {
977-
// It's a virtual environment we can remove it
978-
(_, Ok(true)) => true,
979-
// It doesn't exist at all, we should use it without deleting it to avoid TOCTOU bugs
980-
(Ok(false), Ok(false)) => false,
981-
// If it's not a virtual environment, bail
982-
(Ok(true), Ok(false)) => {
983-
// Unless it's empty, in which case we just ignore it
984-
if venv.read_dir().is_ok_and(|mut dir| dir.next().is_none()) {
985-
false
986-
} else {
951+
impl ProjectEnvironment {
952+
/// Initialize a virtual environment for the current project.
953+
pub(crate) async fn get_or_init(
954+
workspace: &Workspace,
955+
python: Option<PythonRequest>,
956+
install_mirrors: &PythonInstallMirrors,
957+
python_preference: PythonPreference,
958+
python_downloads: PythonDownloads,
959+
connectivity: Connectivity,
960+
native_tls: bool,
961+
allow_insecure_host: &[TrustedHost],
962+
no_config: bool,
963+
active: Option<bool>,
964+
cache: &Cache,
965+
dry_run: bool,
966+
printer: Printer,
967+
) -> Result<Self, ProjectError> {
968+
// Lock the project environment to avoid synchronization issues.
969+
let _lock = ProjectInterpreter::lock(workspace).await?;
970+
971+
match ProjectInterpreter::discover(
972+
workspace,
973+
workspace.install_path().as_ref(),
974+
python,
975+
python_preference,
976+
python_downloads,
977+
connectivity,
978+
native_tls,
979+
allow_insecure_host,
980+
install_mirrors,
981+
no_config,
982+
active,
983+
cache,
984+
printer,
985+
)
986+
.await?
987+
{
988+
// If we found an existing, compatible environment, use it.
989+
ProjectInterpreter::Environment(environment) => Ok(Self::Existing(environment)),
990+
991+
// Otherwise, create a virtual environment with the discovered interpreter.
992+
ProjectInterpreter::Interpreter(interpreter) => {
993+
let venv = workspace.venv(active);
994+
995+
// Avoid removing things that are not virtual environments
996+
let replace = match (venv.try_exists(), venv.join("pyvenv.cfg").try_exists()) {
997+
// It's a virtual environment we can remove it
998+
(_, Ok(true)) => true,
999+
// It doesn't exist at all, we should use it without deleting it to avoid TOCTOU bugs
1000+
(Ok(false), Ok(false)) => false,
1001+
// If it's not a virtual environment, bail
1002+
(Ok(true), Ok(false)) => {
1003+
// Unless it's empty, in which case we just ignore it
1004+
if venv.read_dir().is_ok_and(|mut dir| dir.next().is_none()) {
1005+
false
1006+
} else {
1007+
return Err(ProjectError::InvalidProjectEnvironmentDir(
1008+
venv,
1009+
"it is not a compatible environment but cannot be recreated because it is not a virtual environment".to_string(),
1010+
));
1011+
}
1012+
}
1013+
// Similarly, if we can't _tell_ if it exists we should bail
1014+
(_, Err(err)) | (Err(err), _) => {
9871015
return Err(ProjectError::InvalidProjectEnvironmentDir(
9881016
venv,
989-
"it is not a compatible environment but cannot be recreated because it is not a virtual environment".to_string(),
1017+
format!("it is not a compatible environment but cannot be recreated because uv cannot determine if it is a virtual environment: {err}"),
9901018
));
9911019
}
1020+
};
1021+
1022+
// Under `--dry-run`, avoid modifying the environment.
1023+
if dry_run {
1024+
let environment = PythonEnvironment::from_interpreter(interpreter);
1025+
return Ok(if replace {
1026+
Self::Replaced(environment, venv)
1027+
} else {
1028+
Self::New(environment, venv)
1029+
});
9921030
}
993-
// Similarly, if we can't _tell_ if it exists we should bail
994-
(_, Err(err)) | (Err(err), _) => {
995-
return Err(ProjectError::InvalidProjectEnvironmentDir(
996-
venv,
997-
format!("it is not a compatible environment but cannot be recreated because uv cannot determine if it is a virtual environment: {err}"),
998-
));
999-
}
1000-
};
10011031

1002-
// Remove the existing virtual environment if it doesn't meet the requirements.
1003-
if should_remove {
1004-
match fs_err::remove_dir_all(&venv) {
1005-
Ok(()) => {
1006-
writeln!(
1007-
printer.stderr(),
1008-
"Removed virtual environment at: {}",
1009-
venv.user_display().cyan()
1010-
)?;
1032+
// Remove the existing virtual environment if it doesn't meet the requirements.
1033+
if replace {
1034+
match fs_err::remove_dir_all(&venv) {
1035+
Ok(()) => {
1036+
writeln!(
1037+
printer.stderr(),
1038+
"Removed virtual environment at: {}",
1039+
venv.user_display().cyan()
1040+
)?;
1041+
}
1042+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1043+
Err(e) => return Err(e.into()),
10111044
}
1012-
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1013-
Err(e) => return Err(e.into()),
10141045
}
1015-
}
10161046

1017-
writeln!(
1018-
printer.stderr(),
1019-
"Creating virtual environment at: {}",
1020-
venv.user_display().cyan()
1021-
)?;
1047+
writeln!(
1048+
printer.stderr(),
1049+
"Creating virtual environment at: {}",
1050+
venv.user_display().cyan()
1051+
)?;
1052+
1053+
// Determine a prompt for the environment, in order of preference:
1054+
//
1055+
// 1) The name of the project
1056+
// 2) The name of the directory at the root of the workspace
1057+
// 3) No prompt
1058+
let prompt = workspace
1059+
.pyproject_toml()
1060+
.project
1061+
.as_ref()
1062+
.map(|p| p.name.to_string())
1063+
.or_else(|| {
1064+
workspace
1065+
.install_path()
1066+
.file_name()
1067+
.map(|f| f.to_string_lossy().to_string())
1068+
})
1069+
.map(uv_virtualenv::Prompt::Static)
1070+
.unwrap_or(uv_virtualenv::Prompt::None);
1071+
1072+
let environment = uv_virtualenv::create_venv(
1073+
&venv,
1074+
interpreter,
1075+
prompt,
1076+
false,
1077+
false,
1078+
false,
1079+
false,
1080+
)?;
1081+
1082+
if replace {
1083+
Ok(Self::Replaced(environment, venv))
1084+
} else {
1085+
Ok(Self::New(environment, venv))
1086+
}
1087+
}
1088+
}
1089+
}
10221090

1023-
// Determine a prompt for the environment, in order of preference:
1024-
//
1025-
// 1) The name of the project
1026-
// 2) The name of the directory at the root of the workspace
1027-
// 3) No prompt
1028-
let prompt = workspace
1029-
.pyproject_toml()
1030-
.project
1031-
.as_ref()
1032-
.map(|p| p.name.to_string())
1033-
.or_else(|| {
1034-
workspace
1035-
.install_path()
1036-
.file_name()
1037-
.map(|f| f.to_string_lossy().to_string())
1038-
})
1039-
.map(uv_virtualenv::Prompt::Static)
1040-
.unwrap_or(uv_virtualenv::Prompt::None);
1041-
1042-
Ok(uv_virtualenv::create_venv(
1043-
&venv,
1044-
interpreter,
1045-
prompt,
1046-
false,
1047-
false,
1048-
false,
1049-
false,
1050-
)?)
1091+
/// Convert the [`ProjectEnvironment`] into a [`PythonEnvironment`].
1092+
pub(crate) fn into_environment(self) -> PythonEnvironment {
1093+
match self {
1094+
Self::Existing(environment) => environment,
1095+
Self::Replaced(environment, ..) => environment,
1096+
Self::New(environment, ..) => environment,
10511097
}
10521098
}
10531099
}

0 commit comments

Comments
 (0)