diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 1886dec3c..fbc79bd0b 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -34,6 +34,7 @@ use uv_platform_tags::Tags; use uv_preview::Preview; use uv_pypi_types::{Conflicts, ResolverMarkerEnvironment}; use uv_python::{PythonEnvironment, PythonInstallation}; +use uv_redacted::DisplaySafeUrl; use uv_requirements::{ GroupsSpecification, LookaheadResolver, NamedRequirementsResolver, RequirementsSource, RequirementsSpecification, SourceTree, SourceTreeResolver, @@ -454,6 +455,20 @@ impl ChangedDist { }, } } + + pub(crate) fn version(&self) -> Option<&Version> { + match self { + Self::Local(dist) => Some(dist.installed_version().version()), + Self::Remote(dist) => dist.version(), + } + } + + pub(crate) fn url(&self) -> Option<&DisplaySafeUrl> { + match self { + Self::Local(dist) => dist.installed_version().url(), + Self::Remote(dist) => dist.version_or_url().url().map(|url| &**url), + } + } } /// 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 2542965e8..98b2ce7c7 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; @@ -207,11 +207,12 @@ pub(crate) async fn sync( }) .ok(); - let sync_report = SyncReport { + let mut sync_report = SyncReport { dry_run: dry_run.enabled(), environment: EnvironmentReport::from(&environment), action: SyncAction::from(&environment), target: TargetName::from(&target), + packages: Vec::new(), }; // Show the intermediate results if relevant @@ -292,7 +293,8 @@ pub(crate) async fn sync( ) .await { - Ok(..) => { + Ok(EnvironmentUpdate { changelog, .. }) => { + sync_report.packages = PackageChangeReport::from_changelog(&changelog); // Generate a report for the script without a lockfile let report = Report { schema: SchemaReport::default(), @@ -387,27 +389,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 +418,29 @@ 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()), + }; + + sync_report.packages = PackageChangeReport::from_changelog(&changelog); + + 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}")?; } match outcome { @@ -614,7 +618,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, @@ -825,7 +829,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, @@ -850,7 +854,7 @@ pub(super) async fn do_sync( ) .await?; - Ok(()) + Ok(changelog) } /// Filter out any virtual workspace members. @@ -1254,6 +1258,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)] + packages: Vec, // 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 @@ -1276,6 +1283,7 @@ impl SyncReport { let Self { environment, action, + packages: _, dry_run, target, } = self; @@ -1294,6 +1302,77 @@ impl SyncReport { } } +/// A summary of a single package change performed during sync. +#[derive(Serialize, Debug, Clone)] +struct PackageChangeReport { + /// The normalized package name. + name: String, + /// The resolved version of the package. + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + /// The source for URL-based requirements. + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, + /// The action that was taken for the package. + action: PackageChangeAction, +} + +impl PackageChangeReport { + fn from_changelog(changelog: &Changelog) -> Vec { + let mut changes: Vec<_> = changelog + .uninstalled + .iter() + .map(|dist| Self::from_dist(dist, PackageChangeAction::Removed)) + .chain( + changelog + .installed + .iter() + .map(|dist| Self::from_dist(dist, PackageChangeAction::Added)), + ) + .chain( + changelog + .reinstalled + .iter() + .map(|dist| Self::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)) + }); + changes + } + + fn from_dist(dist: &ChangedDist, action: PackageChangeAction) -> Self { + Self { + name: dist.name().to_string(), + version: dist.version().cloned(), + source: dist.url().map(|url| PackageChangeSourceReport { + url: url.to_string(), + }), + 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 { + Removed, + Added, + Reinstalled, +} + +/// The source for a package change, when it originated from a URL requirement. +#[derive(Serialize, Debug, Clone)] +struct PackageChangeSourceReport { + url: String, +} + /// 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..6a70d07c6 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", + "packages": [ + { + "name": "iniconfig", + "version": "2.0.0", + "action": "added" + } + ] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -464,7 +471,8 @@ fn sync_json() -> Result<()> { "implementation": "cpython" } }, - "action": "check" + "action": "check", + "packages": [] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -503,7 +511,8 @@ fn sync_json() -> Result<()> { "implementation": "cpython" } }, - "action": "check" + "action": "check", + "packages": [] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -569,7 +578,8 @@ fn sync_json() -> Result<()> { "implementation": "cpython" } }, - "action": "check" + "action": "check", + "packages": [] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -629,7 +639,14 @@ fn sync_dry_json() -> Result<()> { "implementation": "cpython" } }, - "action": "create" + "action": "create", + "packages": [ + { + "name": "iniconfig", + "version": "2.0.0", + "action": "added" + } + ] }, "lock": { "path": "[TEMP_DIR]/uv.lock", @@ -6856,7 +6873,24 @@ fn sync_active_script_environment_json() -> Result<()> { "implementation": "cpython" } }, - "action": "create" + "action": "create", + "packages": [ + { + "name": "anyio", + "version": "4.3.0", + "action": "added" + }, + { + "name": "idna", + "version": "3.6", + "action": "added" + }, + { + "name": "sniffio", + "version": "1.3.1", + "action": "added" + } + ] }, "lock": null, "dry_run": false @@ -6902,7 +6936,24 @@ fn sync_active_script_environment_json() -> Result<()> { "implementation": "cpython" } }, - "action": "create" + "action": "create", + "packages": [ + { + "name": "anyio", + "version": "4.3.0", + "action": "added" + }, + { + "name": "idna", + "version": "3.6", + "action": "added" + }, + { + "name": "sniffio", + "version": "1.3.1", + "action": "added" + } + ] }, "lock": null, "dry_run": false @@ -6961,7 +7012,24 @@ fn sync_active_script_environment_json() -> Result<()> { "implementation": "cpython" } }, - "action": "update" + "action": "update", + "packages": [ + { + "name": "anyio", + "version": "4.3.0", + "action": "added" + }, + { + "name": "idna", + "version": "3.6", + "action": "added" + }, + { + "name": "sniffio", + "version": "1.3.1", + "action": "added" + } + ] }, "lock": null, "dry_run": false