use std::fmt::Write;
use std::ops::Deref;
use std::path::Path;
use anyhow::Result;
use itertools::Itertools;
use owo_colors::OwoColorize;
use rustc_hash::FxHashSet;
use serde::Serialize;
use tracing::warn;
use uv_cache::Cache;
use uv_cli::SyncFormat;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DryRun, EditableMode,
ExtrasSpecification, ExtrasSpecificationWithDefaults, HashCheckingMode, InstallOptions,
TargetTriple, Upgrade,
};
use uv_dispatch::BuildDispatch;
use uv_distribution::LoweredExtraBuildDependencies;
use uv_distribution_types::{Dist, Index, Name, Requirement, Resolution, ResolvedDist, SourceDist};
use uv_fs::{PortablePathBuf, Simplified};
use uv_installer::{InstallationStrategy, SitePackages};
use uv_normalize::{DefaultExtras, DefaultGroups, PackageName};
use uv_pep508::{MarkerTree, VersionOrUrl};
use uv_preview::{Preview, PreviewFeature};
use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl};
use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest};
use uv_resolver::{FlatIndex, ForkStrategy, Installable, Lock, PrereleaseMode, ResolutionMode};
use uv_scripts::Pep723Script;
use uv_settings::PythonInstallMirrors;
use uv_types::{BuildIsolation, HashStrategy, SourceTreeEditablePolicy};
use uv_warnings::warn_user;
use uv_workspace::pyproject::Source;
use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache};
use crate::commands::editable::apply_editable_mode;
use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, InstallLogger};
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::{
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;
use crate::settings::{
FrozenSource, InstallerSettingsRef, LockCheck, LockCheckSource, ResolverInstallerSettings,
ResolverSettings,
};
pub(crate) async fn sync(
project_dir: &Path,
lock_check: LockCheck,
frozen: Option<FrozenSource>,
dry_run: DryRun,
active: Option<bool>,
all_packages: bool,
package: Vec<PackageName>,
extras: ExtrasSpecification,
groups: DependencyGroups,
editable: Option<EditableMode>,
install_options: InstallOptions,
modifications: Modifications,
python: Option<String>,
python_platform: Option<TargetTriple>,
install_mirrors: PythonInstallMirrors,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
settings: ResolverInstallerSettings,
client_builder: BaseClientBuilder<'_>,
script: Option<Pep723Script>,
installer_metadata: bool,
concurrency: Concurrency,
no_config: bool,
cache: &Cache,
workspace_cache: &WorkspaceCache,
printer: Printer,
preview: Preview,
output_format: SyncFormat,
) -> Result<ExitStatus> {
if preview.is_enabled(PreviewFeature::JsonOutput) && matches!(output_format, SyncFormat::Json) {
warn_user!(
"The `--output-format json` option is experimental and the schema may change without warning. Pass `--preview-features {}` to disable this warning.",
PreviewFeature::JsonOutput
);
}
let target = if let Some(script) = script {
SyncTarget::Script(script)
} else {
let project = if frozen.is_some() {
VirtualProject::discover(
project_dir,
&DiscoveryOptions {
members: MemberDiscovery::None,
..DiscoveryOptions::default()
},
workspace_cache,
)
.await?
} else if let [name] = package.as_slice() {
VirtualProject::discover_with_package(
project_dir,
&DiscoveryOptions::default(),
workspace_cache,
name.clone(),
)
.await?
} else {
let project = VirtualProject::discover(
project_dir,
&DiscoveryOptions::default(),
workspace_cache,
)
.await?;
for name in &package {
if !project.workspace().packages().contains_key(name) {
return Err(anyhow::anyhow!("Package `{name}` not found in workspace"));
}
}
project
};
SyncTarget::Project(project)
};
let default_groups = match &target {
SyncTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?,
SyncTarget::Script(..) => DefaultGroups::default(),
};
let default_extras = match &target {
SyncTarget::Project(_project) => DefaultExtras::default(),
SyncTarget::Script(..) => DefaultExtras::default(),
};
let groups = groups.with_defaults(default_groups);
let extras = extras.with_defaults(default_extras);
let environment = match &target {
SyncTarget::Project(project) => SyncEnvironment::Project(
ProjectEnvironment::get_or_init(
project.workspace(),
&groups,
python.as_deref().map(PythonRequest::parse),
&install_mirrors,
&client_builder,
python_preference,
python_downloads,
false,
no_config,
active,
cache,
dry_run,
printer,
preview,
)
.await?,
),
SyncTarget::Script(script) => SyncEnvironment::Script(
ScriptEnvironment::get_or_init(
script.into(),
python.as_deref().map(PythonRequest::parse),
&client_builder,
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
active,
cache,
dry_run,
printer,
preview,
)
.await?,
),
};
let _lock = environment
.lock()
.await
.inspect_err(|err| {
warn!("Failed to acquire environment lock: {err}");
})
.ok();
let sync_report = SyncReport {
dry_run: dry_run.enabled(),
environment: EnvironmentReport::from(&environment),
action: SyncAction::from(&environment),
target: TargetName::from(&target),
changes: PackageChangesReport::default(),
};
if let Some(message) = sync_report.format(output_format) {
writeln!(printer.stderr(), "{message}")?;
}
if let SyncTarget::Script(script) = &target {
let lockfile = LockTarget::from(script).lock_path();
if !lockfile.is_file() {
if frozen.is_some() {
return Err(anyhow::anyhow!(
"`uv sync --frozen` requires a script lockfile; run `{}` to lock the script",
format!("uv lock --script {}", script.path.user_display()).green(),
));
}
if let LockCheck::Enabled(lock_check) = lock_check {
return Err(anyhow::anyhow!(
"`uv sync {lock_check}` requires a script lockfile; run `{}` to lock the script",
format!("uv lock --script {}", script.path.user_display()).green(),
));
}
let spec = script_specification(
script.into(),
&settings.resolver,
client_builder.credentials_cache(),
)?
.unwrap_or_default();
let script_extra_build_requires = script_extra_build_requires(
script.into(),
&settings.resolver,
client_builder.credentials_cache(),
)?
.into_inner();
let build_constraints = script
.metadata
.tool
.as_ref()
.and_then(|tool| {
tool.uv
.as_ref()
.and_then(|uv| uv.build_constraint_dependencies.as_ref())
})
.map(|constraints| {
Constraints::from_requirements(
constraints
.iter()
.map(|constraint| Requirement::from(constraint.clone())),
)
});
match update_environment(
environment.clone(),
spec,
modifications,
python_platform.as_ref(),
SourceTreeEditablePolicy::Project,
build_constraints.unwrap_or_default(),
script_extra_build_requires,
&settings,
&client_builder,
&PlatformState::default(),
Box::new(DefaultResolveLogger),
Box::new(DefaultInstallLogger),
installer_metadata,
&concurrency,
cache,
workspace_cache,
dry_run,
printer,
preview,
)
.await
{
Ok(EnvironmentUpdate { changelog, .. }) => {
write_sync_report(
&target,
&environment,
&changelog,
None,
dry_run,
output_format,
printer,
)?;
return Ok(ExitStatus::Success);
}
Err(ProjectError::Operation(operations::Error::OutdatedEnvironment(changelog))) => {
write_sync_report(
&target,
&environment,
&changelog,
None,
dry_run,
output_format,
printer,
)?;
return diagnostics::OperationDiagnostic::with_system_certs(
client_builder.system_certs(),
)
.report(operations::Error::OutdatedEnvironment(changelog))
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_system_certs(
client_builder.system_certs(),
)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
Err(err) => return Err(err.into()),
}
}
}
let state = UniversalState::default();
let mode = if let Some(frozen_source) = frozen {
LockMode::Frozen(frozen_source.into())
} else if let LockCheck::Enabled(lock_check) = lock_check {
LockMode::Locked(environment.interpreter(), lock_check)
} else if dry_run.enabled() {
LockMode::DryRun(environment.interpreter())
} else {
LockMode::Write(environment.interpreter())
};
let lock_target = match &target {
SyncTarget::Project(project) => LockTarget::from(project.workspace()),
SyncTarget::Script(script) => LockTarget::from(script),
};
let outcome = match Box::pin(
LockOperation::new(
mode,
&settings.resolver,
&client_builder,
&state,
Box::new(DefaultResolveLogger),
&concurrency,
cache,
workspace_cache,
printer,
preview,
)
.execute(lock_target),
)
.await
{
Ok(result) => Outcome::Success(result),
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_system_certs(
client_builder.system_certs(),
)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
Err(ProjectError::LockMismatch(prev, cur, lock_source)) => {
if dry_run.enabled() {
Outcome::LockMismatch(prev, cur, lock_source)
} else {
writeln!(
printer.stderr(),
"{}",
ProjectError::LockMismatch(prev, cur, lock_source)
.to_string()
.bold()
)?;
return Ok(ExitStatus::Failure);
}
}
Err(err) => return Err(err.into()),
};
let lock_report = LockReport::from((&lock_target, &mode, &outcome));
if let Some(message) = lock_report.format(output_format) {
writeln!(printer.stderr(), "{message}")?;
}
let sync_target = identify_installation_target(&target, outcome.lock(), all_packages, &package);
if let SyncTarget::Project(project) = &target {
let roots = sync_target.roots().collect::<FxHashSet<_>>();
for (name, member) in project.workspace().packages() {
if roots.contains(name)
&& member.pyproject_toml().has_scripts()
&& !member.pyproject_toml().is_package(true)
{
warn_user!(
"Skipping installation of entry points (`project.scripts`) for package `{}` because this project is not packaged; to install entry points, set `tool.uv.package = true` or define a `build-system`",
name
);
}
}
}
let state = state.fork();
let changelog = match do_sync(
sync_target,
&environment,
&extras,
&groups,
editable,
install_options,
modifications,
python_platform.as_ref(),
(&settings).into(),
&client_builder,
&state,
Box::new(DefaultInstallLogger),
installer_metadata,
&concurrency,
cache,
workspace_cache,
dry_run,
printer,
preview,
)
.await
{
Ok(changelog) => changelog,
Err(ProjectError::Operation(operations::Error::OutdatedEnvironment(changelog))) => {
write_sync_report(
&target,
&environment,
&changelog,
Some(lock_report),
dry_run,
output_format,
printer,
)?;
return diagnostics::OperationDiagnostic::with_system_certs(
client_builder.system_certs(),
)
.report(operations::Error::OutdatedEnvironment(changelog))
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_system_certs(
client_builder.system_certs(),
)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
Err(err) => return Err(err.into()),
};
write_sync_report(
&target,
&environment,
&changelog,
Some(lock_report),
dry_run,
output_format,
printer,
)?;
match outcome {
Outcome::Success(..) => Ok(ExitStatus::Success),
Outcome::LockMismatch(prev, cur, lock_source) => {
writeln!(
printer.stderr(),
"{}",
ProjectError::LockMismatch(prev, cur, lock_source)
.to_string()
.bold()
)?;
Ok(ExitStatus::Failure)
}
}
}
#[derive(Debug)]
#[expect(clippy::large_enum_variant)]
enum Outcome {
Success(LockResult),
LockMismatch(Option<Box<Lock>>, Box<Lock>, LockCheckSource),
}
impl Outcome {
fn lock(&self) -> &Lock {
match self {
Self::Success(lock) => match lock {
LockResult::Changed(_, lock) => lock,
LockResult::Unchanged(lock) => lock,
},
Self::LockMismatch(_prev, cur, _lock_source) => cur,
}
}
}
fn identify_installation_target<'a>(
target: &'a SyncTarget,
lock: &'a Lock,
all_packages: bool,
package: &'a [PackageName],
) -> InstallTarget<'a> {
match &target {
SyncTarget::Project(project) => {
match &project {
VirtualProject::Project(project) => {
if all_packages {
InstallTarget::Workspace {
workspace: project.workspace(),
lock,
}
} else {
match package {
[] => InstallTarget::Project {
workspace: project.workspace(),
name: project.project_name(),
lock,
},
[name] => InstallTarget::Project {
workspace: project.workspace(),
name,
lock,
},
names => InstallTarget::Projects {
workspace: project.workspace(),
names,
lock,
},
}
}
}
VirtualProject::NonProject(workspace) => {
if all_packages {
InstallTarget::NonProjectWorkspace { workspace, lock }
} else {
match package {
[] => InstallTarget::NonProjectWorkspace { workspace, lock },
[name] => InstallTarget::Project {
workspace,
name,
lock,
},
names => InstallTarget::Projects {
workspace,
names,
lock,
},
}
}
}
}
}
SyncTarget::Script(script) => InstallTarget::Script { script, lock },
}
}
#[derive(Debug, Clone)]
#[expect(clippy::large_enum_variant)]
enum SyncTarget {
Project(VirtualProject),
Script(Pep723Script),
}
impl SyncTarget {
fn project(&self) -> Option<&VirtualProject> {
match self {
Self::Project(project) => Some(project),
Self::Script(_) => None,
}
}
fn script(&self) -> Option<&Pep723Script> {
match self {
Self::Project(_) => None,
Self::Script(script) => Some(script),
}
}
}
#[derive(Debug)]
enum SyncEnvironment {
Project(ProjectEnvironment),
Script(ScriptEnvironment),
}
impl SyncEnvironment {
fn dry_run_target(&self) -> Option<&Path> {
match self {
Self::Project(env) => env.dry_run_target(),
Self::Script(env) => env.dry_run_target(),
}
}
}
impl Deref for SyncEnvironment {
type Target = PythonEnvironment;
fn deref(&self) -> &Self::Target {
match self {
Self::Project(environment) => environment,
Self::Script(environment) => environment,
}
}
}
pub(super) async fn do_sync(
target: InstallTarget<'_>,
venv: &PythonEnvironment,
extras: &ExtrasSpecificationWithDefaults,
groups: &DependencyGroupsWithDefaults,
editable: Option<EditableMode>,
install_options: InstallOptions,
modifications: Modifications,
python_platform: Option<&TargetTriple>,
settings: InstallerSettingsRef<'_>,
client_builder: &BaseClientBuilder<'_>,
state: &PlatformState,
logger: Box<dyn InstallLogger>,
installer_metadata: bool,
concurrency: &Concurrency,
cache: &Cache,
workspace_cache: &WorkspaceCache,
dry_run: DryRun,
printer: Printer,
preview: Preview,
) -> Result<Changelog, ProjectError> {
let InstallerSettingsRef {
index_locations,
index_strategy,
keyring_provider,
dependency_metadata,
config_setting,
config_settings_package,
build_isolation,
extra_build_dependencies,
extra_build_variables,
exclude_newer,
link_mode,
compile_bytecode,
reinstall,
build_options,
sources,
} = settings;
let extra_build_requires = match &target {
InstallTarget::Workspace { workspace, .. }
| InstallTarget::Project { workspace, .. }
| InstallTarget::Projects { workspace, .. }
| InstallTarget::NonProjectWorkspace { workspace, .. } => {
LoweredExtraBuildDependencies::from_workspace(
extra_build_dependencies.clone(),
workspace,
index_locations,
&sources,
client_builder.credentials_cache(),
)?
}
InstallTarget::Script { script, .. } => {
let resolver_settings = ResolverSettings {
build_options: build_options.clone(),
config_setting: config_setting.clone(),
config_settings_package: config_settings_package.clone(),
dependency_metadata: dependency_metadata.clone(),
exclude_newer: exclude_newer.clone(),
fork_strategy: ForkStrategy::default(),
index_locations: index_locations.clone(),
index_strategy,
keyring_provider,
link_mode,
build_isolation: build_isolation.clone(),
extra_build_dependencies: extra_build_dependencies.clone(),
extra_build_variables: extra_build_variables.clone(),
prerelease: PrereleaseMode::default(),
resolution: ResolutionMode::default(),
sources: sources.clone(),
torch_backend: None,
upgrade: Upgrade::default(),
};
script_extra_build_requires(
(*script).into(),
&resolver_settings,
client_builder.credentials_cache(),
)?
}
}
.into_inner();
let client_builder = client_builder.clone().keyring(keyring_provider);
if !target
.lock()
.requires_python()
.contains(venv.interpreter().python_version())
{
return Err(ProjectError::LockedPythonIncompatibility(
venv.interpreter().python_version().clone(),
target.lock().requires_python().clone(),
));
}
detect_conflicts(&target, extras, groups)?;
target.validate_extras(extras)?;
target.validate_groups(groups)?;
let marker_env = resolution_markers(None, python_platform, venv.interpreter());
let environments = target.lock().supported_environments();
if !environments.is_empty() {
if !environments
.iter()
.any(|env| env.evaluate(&marker_env, &[]))
{
return Err(ProjectError::LockedPlatformIncompatibility(
target
.lock()
.simplified_supported_environments()
.into_iter()
.filter_map(MarkerTree::contents)
.map(|env| format!("`{env}`"))
.join(", "),
));
}
}
let tags = resolution_tags(None, python_platform, venv.interpreter())?;
let resolution = target.to_resolution(
&marker_env,
&tags,
extras,
groups,
build_options,
&install_options,
)?;
let resolution = apply_no_virtual_project(resolution);
let resolution = apply_editable_mode(resolution, editable);
let extra_build_requires = extra_build_requires.match_runtime(&resolution)?;
store_credentials_from_target(target, &client_builder);
let client = RegistryClientBuilder::new(client_builder, cache.clone())
.index_locations(index_locations.clone())
.index_strategy(index_strategy)
.markers(venv.interpreter().markers())
.platform(venv.interpreter().platform())
.build()?;
let build_isolation = match build_isolation {
uv_configuration::BuildIsolation::Isolate => BuildIsolation::Isolated,
uv_configuration::BuildIsolation::Shared => BuildIsolation::Shared(venv),
uv_configuration::BuildIsolation::SharedPackage(packages) => {
BuildIsolation::SharedPackage(venv, packages)
}
};
let build_constraints = target.build_constraints();
let build_hasher = HashStrategy::default();
let hasher = HashStrategy::from_resolution(&resolution, HashCheckingMode::Verify)?;
let flat_index = {
let client = FlatIndexClient::new(client.cached_client(), client.connectivity(), cache);
let entries = client
.fetch_all(index_locations.flat_indexes().map(Index::url))
.await?;
FlatIndex::from_entries(entries, Some(&tags), &hasher, build_options)
};
let build_dispatch = BuildDispatch::new(
&client,
cache,
&build_constraints,
venv.interpreter(),
index_locations,
&flat_index,
dependency_metadata,
state.clone().into_inner(),
index_strategy,
config_setting,
config_settings_package,
build_isolation,
&extra_build_requires,
extra_build_variables,
link_mode,
build_options,
&build_hasher,
exclude_newer.clone(),
sources.clone(),
SourceTreeEditablePolicy::Project,
workspace_cache.clone(),
concurrency.clone(),
preview,
);
let site_packages = SitePackages::from_environment(venv)?;
let changelog = operations::install(
&resolution,
site_packages,
InstallationStrategy::Strict,
modifications,
reinstall,
build_options,
link_mode,
compile_bytecode,
&hasher,
&tags,
&client,
state.in_flight(),
concurrency,
&build_dispatch,
cache,
venv,
logger,
installer_metadata,
dry_run,
printer,
preview,
)
.await?;
Ok(changelog)
}
fn apply_no_virtual_project(resolution: Resolution) -> Resolution {
resolution.filter(|dist| {
let ResolvedDist::Installable { dist, .. } = dist else {
return true;
};
let Dist::Source(dist) = dist.as_ref() else {
return true;
};
let SourceDist::Directory(dist) = dist else {
return true;
};
!dist.r#virtual.unwrap_or(false)
})
}
fn store_credentials_from_target(target: InstallTarget<'_>, client_builder: &BaseClientBuilder) {
for index in target.indexes() {
if let Some(credentials) = index.credentials() {
if let Some(root_url) = index.root_url() {
client_builder.store_credentials(&root_url, credentials.clone());
}
client_builder.store_credentials(index.raw_url(), credentials);
}
}
for source in target.sources() {
match source {
Source::Git { git, .. } => {
uv_git::store_credentials_from_url(git);
}
Source::Url { url, .. } => {
client_builder.store_credentials_from_url(url);
}
_ => {}
}
}
for requirement in target.requirements() {
let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
continue;
};
match &url.parsed_url {
ParsedUrl::Git(ParsedGitUrl { url, .. }) => {
uv_git::store_credentials_from_url(url.url());
}
ParsedUrl::Archive(ParsedArchiveUrl { url, .. }) => {
client_builder.store_credentials_from_url(url);
}
_ => {}
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
struct WorkspaceReport {
path: PortablePathBuf,
}
impl From<&Workspace> for WorkspaceReport {
fn from(workspace: &Workspace) -> Self {
Self {
path: workspace.install_path().as_path().into(),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
struct ProjectReport {
path: PortablePathBuf,
workspace: WorkspaceReport,
}
impl From<&VirtualProject> for ProjectReport {
fn from(project: &VirtualProject) -> Self {
Self {
path: project.root().into(),
workspace: WorkspaceReport::from(project.workspace()),
}
}
}
impl From<&SyncTarget> for TargetName {
fn from(target: &SyncTarget) -> Self {
match target {
SyncTarget::Project(_) => Self::Project,
SyncTarget::Script(_) => Self::Script,
}
}
}
#[derive(Serialize, Debug)]
struct ScriptReport {
path: PortablePathBuf,
}
impl From<&Pep723Script> for ScriptReport {
fn from(script: &Pep723Script) -> Self {
Self {
path: script.path.as_path().into(),
}
}
}
#[derive(Serialize, Debug, Default)]
#[serde(rename_all = "snake_case")]
enum SchemaVersion {
#[default]
Preview,
}
#[derive(Serialize, Debug, Default)]
struct SchemaReport {
version: SchemaVersion,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
struct Report {
schema: SchemaReport,
target: TargetName,
#[serde(skip_serializing_if = "Option::is_none")]
project: Option<ProjectReport>,
#[serde(skip_serializing_if = "Option::is_none")]
script: Option<ScriptReport>,
sync: SyncReport,
lock: Option<LockReport>,
dry_run: bool,
}
#[derive(Debug, Serialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
enum TargetName {
Project,
Script,
}
impl std::fmt::Display for TargetName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Project => write!(f, "project"),
Self::Script => write!(f, "script"),
}
}
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
enum SyncAction {
Check,
Update,
Replace,
Create,
}
impl From<&SyncEnvironment> for SyncAction {
fn from(env: &SyncEnvironment) -> Self {
match &env {
SyncEnvironment::Project(ProjectEnvironment::Existing(..)) => Self::Check,
SyncEnvironment::Project(ProjectEnvironment::Created(..)) => Self::Create,
SyncEnvironment::Project(ProjectEnvironment::WouldCreate(..)) => Self::Create,
SyncEnvironment::Project(ProjectEnvironment::WouldReplace(..)) => Self::Replace,
SyncEnvironment::Project(ProjectEnvironment::Replaced(..)) => Self::Update,
SyncEnvironment::Script(ScriptEnvironment::Existing(..)) => Self::Check,
SyncEnvironment::Script(ScriptEnvironment::Created(..)) => Self::Create,
SyncEnvironment::Script(ScriptEnvironment::WouldCreate(..)) => Self::Create,
SyncEnvironment::Script(ScriptEnvironment::WouldReplace(..)) => Self::Replace,
SyncEnvironment::Script(ScriptEnvironment::Replaced(..)) => Self::Update,
}
}
}
impl SyncAction {
fn message(&self, target: TargetName, dry_run: bool) -> Option<&'static str> {
let message = if dry_run {
match self {
Self::Check => "Would use",
Self::Update => "Would update",
Self::Replace => "Would replace",
Self::Create => "Would create",
}
} else {
let is_project = matches!(target, TargetName::Project);
match self {
Self::Check | Self::Update | Self::Create if is_project => {
return None;
}
Self::Check => "Using",
Self::Update => "Updating",
Self::Replace => "Replacing",
Self::Create => "Creating",
}
};
Some(message)
}
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
enum LockAction {
Use,
Check,
Update,
Create,
}
impl LockAction {
fn message(&self, dry_run: bool) -> Option<&'static str> {
let message = if dry_run {
match self {
Self::Use => return None,
Self::Check => "Found up-to-date",
Self::Update => "Would update",
Self::Create => "Would create",
}
} else {
return None;
};
Some(message)
}
}
#[derive(Serialize, Debug)]
struct PythonReport {
path: PortablePathBuf,
version: uv_pep508::StringVersion,
implementation: String,
}
impl From<&uv_python::Interpreter> for PythonReport {
fn from(interpreter: &uv_python::Interpreter) -> Self {
Self {
path: interpreter.sys_executable().into(),
version: interpreter.python_full_version().clone(),
implementation: interpreter.implementation_name().to_string(),
}
}
}
impl PythonReport {
#[must_use]
fn with_path(mut self, path: PortablePathBuf) -> Self {
self.path = path;
self
}
}
#[derive(Serialize, Debug)]
struct EnvironmentReport {
path: PortablePathBuf,
python: PythonReport,
}
impl From<&PythonEnvironment> for EnvironmentReport {
fn from(env: &PythonEnvironment) -> Self {
Self {
python: PythonReport::from(env.interpreter()),
path: env.root().into(),
}
}
}
impl From<&SyncEnvironment> for EnvironmentReport {
fn from(env: &SyncEnvironment) -> Self {
let report = Self::from(&**env);
if let Some(path) = env.dry_run_target() {
report.with_path(path.into())
} else {
report
}
}
}
impl EnvironmentReport {
#[must_use]
fn with_path(mut self, path: PortablePathBuf) -> Self {
let python_path = &self.python.path;
if let Ok(python_path) = python_path.as_ref().strip_prefix(self.path) {
let new_path = path.as_ref().to_path_buf().join(python_path);
self.python = self.python.with_path(new_path.as_path().into());
}
self.path = path;
self
}
}
#[derive(Serialize, Debug)]
struct SyncReport {
environment: EnvironmentReport,
action: SyncAction,
#[serde(default)]
changes: PackageChangesReport,
#[serde(skip)]
dry_run: bool,
#[serde(skip)]
target: TargetName,
}
impl SyncReport {
fn format(&self, output_format: SyncFormat) -> Option<String> {
match output_format {
SyncFormat::Json => None,
SyncFormat::Text => self.to_human_readable_string(),
}
}
fn to_human_readable_string(&self) -> Option<String> {
let Self {
environment,
action,
changes: _,
dry_run,
target,
} = self;
let action = action.message(*target, *dry_run)?;
let message = format!(
"{action} {target} environment at: {path}",
path = environment.path.user_display().cyan(),
);
if *dry_run {
return Some(message.dimmed().to_string());
}
Some(message)
}
}
#[derive(Serialize, Debug, Clone, Default)]
struct PackageChangesReport(Vec<PackageChangeReport>);
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)
}
}
#[derive(Serialize, Debug, Clone)]
struct PackageChangeReport {
name: PackageName,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<uv_pep440::Version>,
action: PackageChangeAction,
}
impl PackageChangeReport {
fn from_dist(dist: &ChangedDist, action: PackageChangeAction) -> Self {
Self {
name: dist.name().clone(),
version: dist.version().cloned(),
action,
}
}
}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
enum PackageChangeAction {
Uninstalled,
Installed,
Reinstalled,
}
#[derive(Debug, Serialize)]
struct LockReport {
path: PortablePathBuf,
action: LockAction,
#[serde(skip)]
dry_run: bool,
}
impl From<(&LockTarget<'_>, &LockMode<'_>, &Outcome)> for LockReport {
fn from((target, mode, outcome): (&LockTarget, &LockMode, &Outcome)) -> Self {
Self {
path: target.lock_path().deref().into(),
action: match outcome {
Outcome::Success(result) => {
match result {
LockResult::Unchanged(..) => match mode {
LockMode::Frozen(_) => LockAction::Use,
LockMode::DryRun(_) | LockMode::Locked(_, _) | LockMode::Write(_) => {
LockAction::Check
}
},
LockResult::Changed(None, ..) => LockAction::Create,
LockResult::Changed(Some(_), ..) => LockAction::Update,
}
}
Outcome::LockMismatch(..) => LockAction::Check,
},
dry_run: matches!(mode, LockMode::DryRun(_)),
}
}
}
impl LockReport {
fn format(&self, output_format: SyncFormat) -> Option<String> {
match output_format {
SyncFormat::Json => None,
SyncFormat::Text => self.to_human_readable_string(),
}
}
fn to_human_readable_string(&self) -> Option<String> {
let Self {
path,
action,
dry_run,
} = self;
let action = action.message(*dry_run)?;
let message = format!(
"{action} lockfile at: {path}",
path = path.user_display().cyan(),
);
if *dry_run {
return Some(message.dimmed().to_string());
}
Some(message)
}
}
impl Report {
fn format(&self, output_format: SyncFormat) -> Option<String> {
match output_format {
SyncFormat::Json => serde_json::to_string_pretty(self).ok(),
SyncFormat::Text => None,
}
}
}
fn write_sync_report(
target: &SyncTarget,
environment: &SyncEnvironment,
changelog: &Changelog,
lock: Option<LockReport>,
dry_run: DryRun,
output_format: SyncFormat,
printer: Printer,
) -> Result<()> {
let report = Report {
schema: SchemaReport::default(),
target: TargetName::from(target),
project: target.project().map(ProjectReport::from),
script: target.script().map(ScriptReport::from),
sync: SyncReport {
environment: EnvironmentReport::from(environment),
action: SyncAction::from(environment),
changes: PackageChangesReport::from_changelog(changelog),
dry_run: dry_run.enabled(),
target: TargetName::from(target),
},
lock,
dry_run: dry_run.enabled(),
};
if let Some(output) = report.format(output_format) {
writeln!(printer.stdout_important(), "{output}")?;
}
Ok(())
}