From c2e1983cd64df4c1642012fba04c2d084aea6cda Mon Sep 17 00:00:00 2001 From: Tomasz Kramkowski Date: Thu, 18 Dec 2025 19:37:02 +0000 Subject: [PATCH] Summarize package changes in `uv sync` json format output (#16981) ## Summary Implement #16653 by making `uv sync --output-format=json` output information about package changes. ## Test Plan Additional tests to test the cases where there is no known package version _may_ be beneficial but as the information used is the same as the information used by the dry run logging now, I don't think that's strictly necessary as those cases are tested. --------- Co-authored-by: Liam --- crates/uv-distribution-types/src/lib.rs | 4 +- crates/uv/src/commands/pip/operations.rs | 7 ++ crates/uv/src/commands/project/remove.rs | 2 +- crates/uv/src/commands/project/run.rs | 4 +- crates/uv/src/commands/project/sync.rs | 123 +++++++++++++++++----- crates/uv/src/commands/project/version.rs | 2 +- crates/uv/tests/it/sync.rs | 84 +++++++++++++-- 7 files changed, 186 insertions(+), 40 deletions(-) diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index c0a6cd2bd..cbe7668ec 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -122,9 +122,9 @@ pub enum VersionOrUrlRef<'a, T: Pep508Url = VerbatimUrl> { Url(&'a T), } -impl VersionOrUrlRef<'_, T> { +impl<'a, T: Pep508Url> VersionOrUrlRef<'a, T> { /// If it is a URL, return its value. - pub fn url(&self) -> Option<&T> { + pub fn url(&self) -> Option<&'a T> { match self { Self::Version(_) => None, Self::Url(url) => Some(url), diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 1886dec3c..3a84b34f0 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -454,6 +454,13 @@ impl ChangedDist { }, } } + + pub(crate) fn version(&self) -> Option<&Version> { + match self { + Self::Local(dist) => Some(dist.installed_version().version()), + Self::Remote(dist) => dist.version(), + } + } } /// A summary of the changes made to the environment during an installation. diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 6487c5484..6b79cd710 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -375,7 +375,7 @@ pub(crate) async fn remove( ) .await { - Ok(()) => {} + Ok(_) => {} Err(ProjectError::Operation(err)) => { return diagnostics::OperationDiagnostic::native_tls(client_builder.is_native_tls()) .report(err) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index dc8b201d0..7e912c2d0 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -339,7 +339,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl ) .await { - Ok(()) => {} + Ok(_) => {} Err(ProjectError::Operation(err)) => { return diagnostics::OperationDiagnostic::native_tls( client_builder.is_native_tls(), @@ -867,7 +867,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl ) .await { - Ok(()) => {} + Ok(_) => {} Err(ProjectError::Operation(err)) => { return diagnostics::OperationDiagnostic::native_tls( client_builder.is_native_tls(), diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index e7a141940..c16cf4e44 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -19,7 +19,7 @@ use uv_configuration::{ use uv_dispatch::BuildDispatch; use uv_distribution::LoweredExtraBuildDependencies; use uv_distribution_types::{ - DirectorySourceDist, Dist, Index, Requirement, Resolution, ResolvedDist, SourceDist, + DirectorySourceDist, Dist, Index, Name, Requirement, Resolution, ResolvedDist, SourceDist, }; use uv_fs::{PortablePathBuf, Simplified}; use uv_installer::{InstallationStrategy, SitePackages}; @@ -37,16 +37,16 @@ use uv_workspace::pyproject::Source; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache}; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, InstallLogger}; -use crate::commands::pip::operations::Modifications; +use crate::commands::pip::operations::{ChangedDist, Changelog, Modifications}; use crate::commands::pip::resolution_markers; use crate::commands::pip::{operations, resolution_tags}; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{LockMode, LockOperation, LockResult}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - PlatformState, ProjectEnvironment, ProjectError, ScriptEnvironment, UniversalState, - default_dependency_groups, detect_conflicts, script_extra_build_requires, script_specification, - update_environment, + EnvironmentUpdate, PlatformState, ProjectEnvironment, ProjectError, ScriptEnvironment, + UniversalState, default_dependency_groups, detect_conflicts, script_extra_build_requires, + script_specification, update_environment, }; use crate::commands::{ExitStatus, diagnostics}; use crate::printer::Printer; @@ -212,6 +212,7 @@ pub(crate) async fn sync( environment: EnvironmentReport::from(&environment), action: SyncAction::from(&environment), target: TargetName::from(&target), + changes: PackageChangesReport::default(), }; // Show the intermediate results if relevant @@ -292,14 +293,17 @@ pub(crate) async fn sync( ) .await { - Ok(..) => { + Ok(EnvironmentUpdate { changelog, .. }) => { // Generate a report for the script without a lockfile let report = Report { schema: SchemaReport::default(), target: TargetName::from(&target), project: None, script: Some(ScriptReport::from(script)), - sync: sync_report, + sync: SyncReport { + changes: PackageChangesReport::from_changelog(&changelog), + ..sync_report + }, lock: None, dry_run: dry_run.enabled(), }; @@ -387,27 +391,13 @@ pub(crate) async fn sync( writeln!(printer.stderr(), "{message}")?; } - let report = Report { - schema: SchemaReport::default(), - target: TargetName::from(&target), - project: target.project().map(ProjectReport::from), - script: target.script().map(ScriptReport::from), - sync: sync_report, - lock: Some(lock_report), - dry_run: dry_run.enabled(), - }; - - if let Some(output) = report.format(output_format) { - writeln!(printer.stdout_important(), "{output}")?; - } - // Identify the installation target. let sync_target = identify_installation_target(&target, outcome.lock(), all_packages, &package); let state = state.fork(); // Perform the sync operation. - match do_sync( + let changelog = match do_sync( sync_target, &environment, &extras, @@ -430,13 +420,30 @@ pub(crate) async fn sync( ) .await { - Ok(()) => {} + Ok(changelog) => changelog, Err(ProjectError::Operation(err)) => { return diagnostics::OperationDiagnostic::native_tls(client_builder.is_native_tls()) .report(err) .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())); } Err(err) => return Err(err.into()), + }; + + let report = Report { + schema: SchemaReport::default(), + target: TargetName::from(&target), + project: target.project().map(ProjectReport::from), + script: target.script().map(ScriptReport::from), + sync: SyncReport { + changes: PackageChangesReport::from_changelog(&changelog), + ..sync_report + }, + lock: Some(lock_report), + dry_run: dry_run.enabled(), + }; + + if let Some(output) = report.format(output_format) { + writeln!(printer.stdout_important(), "{output}")?; } match outcome { @@ -614,7 +621,7 @@ pub(super) async fn do_sync( dry_run: DryRun, printer: Printer, preview: Preview, -) -> Result<(), ProjectError> { +) -> Result { // Extract the project settings. let InstallerSettingsRef { index_locations, @@ -826,7 +833,7 @@ pub(super) async fn do_sync( let site_packages = SitePackages::from_environment(venv)?; // Sync the environment. - operations::install( + let changelog = operations::install( &resolution, site_packages, InstallationStrategy::Strict, @@ -851,7 +858,7 @@ pub(super) async fn do_sync( ) .await?; - Ok(()) + Ok(changelog) } /// Filter out any virtual workspace members. @@ -1255,6 +1262,9 @@ struct SyncReport { environment: EnvironmentReport, /// The action performed during the sync, e.g., what was done to the environment. action: SyncAction, + /// The packages that changed during the sync. + #[serde(default)] + changes: PackageChangesReport, // We store these fields so the report can format itself self-contained, but the outer // [`Report`] is intended to include these in user-facing output @@ -1277,6 +1287,7 @@ impl SyncReport { let Self { environment, action, + changes: _, dry_run, target, } = self; @@ -1295,6 +1306,66 @@ impl SyncReport { } } +/// A summary of all package changes performed during sync. +#[derive(Serialize, Debug, Clone, Default)] +struct PackageChangesReport(Vec); + +impl PackageChangesReport { + fn from_changelog(changelog: &Changelog) -> Self { + let mut changes: Vec<_> = + changelog + .uninstalled + .iter() + .map(|dist| PackageChangeReport::from_dist(dist, PackageChangeAction::Uninstalled)) + .chain(changelog.installed.iter().map(|dist| { + PackageChangeReport::from_dist(dist, PackageChangeAction::Installed) + })) + .chain(changelog.reinstalled.iter().map(|dist| { + PackageChangeReport::from_dist(dist, PackageChangeAction::Reinstalled) + })) + .collect(); + + changes.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| a.action.cmp(&b.action)) + .then_with(|| a.version.cmp(&b.version)) + }); + Self(changes) + } +} + +/// A summary of a single package change performed during sync. +#[derive(Serialize, Debug, Clone)] +struct PackageChangeReport { + /// The normalized package name. + name: PackageName, + /// The resolved version of the package. + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + /// The action that was taken for the package. + action: PackageChangeAction, +} + +impl PackageChangeReport { + fn from_dist(dist: &ChangedDist, action: PackageChangeAction) -> Self { + Self { + name: dist.name().clone(), + version: dist.version().cloned(), + action, + } + } +} + +/// The action taken on an individual package during sync. +#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +enum PackageChangeAction { + Uninstalled, + Installed, + Reinstalled, +} + /// The report for a lock operation. #[derive(Debug, Serialize)] struct LockReport { diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index fd81992a2..7de9f7830 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -680,7 +680,7 @@ async fn lock_and_sync( ) .await { - Ok(()) => {} + Ok(_) => {} Err(ProjectError::Operation(err)) => { return diagnostics::OperationDiagnostic::native_tls(client_builder.is_native_tls()) .report(err) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 35df3d50b..82bc1b774 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -420,7 +420,14 @@ fn sync_json() -> Result<()> { "implementation": "cpython" } }, - "action": "check" + "action": "check", + "changes": [ + { + "name": "iniconfig", + "version": "2.0.0", + "action": "installed" + } + ] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -464,7 +471,8 @@ fn sync_json() -> Result<()> { "implementation": "cpython" } }, - "action": "check" + "action": "check", + "changes": [] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -503,7 +511,8 @@ fn sync_json() -> Result<()> { "implementation": "cpython" } }, - "action": "check" + "action": "check", + "changes": [] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -569,7 +578,8 @@ fn sync_json() -> Result<()> { "implementation": "cpython" } }, - "action": "check" + "action": "check", + "changes": [] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -629,7 +639,14 @@ fn sync_dry_json() -> Result<()> { "implementation": "cpython" } }, - "action": "create" + "action": "create", + "changes": [ + { + "name": "iniconfig", + "version": "2.0.0", + "action": "installed" + } + ] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -6856,7 +6873,24 @@ fn sync_active_script_environment_json() -> Result<()> { "implementation": "cpython" } }, - "action": "create" + "action": "create", + "changes": [ + { + "name": "anyio", + "version": "4.3.0", + "action": "installed" + }, + { + "name": "idna", + "version": "3.6", + "action": "installed" + }, + { + "name": "sniffio", + "version": "1.3.1", + "action": "installed" + } + ] }, "lock": null, "dry_run": false @@ -6902,7 +6936,24 @@ fn sync_active_script_environment_json() -> Result<()> { "implementation": "cpython" } }, - "action": "create" + "action": "create", + "changes": [ + { + "name": "anyio", + "version": "4.3.0", + "action": "installed" + }, + { + "name": "idna", + "version": "3.6", + "action": "installed" + }, + { + "name": "sniffio", + "version": "1.3.1", + "action": "installed" + } + ] }, "lock": null, "dry_run": false @@ -6961,7 +7012,24 @@ fn sync_active_script_environment_json() -> Result<()> { "implementation": "cpython" } }, - "action": "update" + "action": "update", + "changes": [ + { + "name": "anyio", + "version": "4.3.0", + "action": "installed" + }, + { + "name": "idna", + "version": "3.6", + "action": "installed" + }, + { + "name": "sniffio", + "version": "1.3.1", + "action": "installed" + } + ] }, "lock": null, "dry_run": false