use std::env;
use std::ffi::OsStr;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use clap::ValueEnum;
use itertools::Itertools;
use owo_colors::OwoColorize;
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::{
Concurrency, DependencyGroups, EditableMode, ExportFormat, ExtrasSpecification, InstallOptions,
};
use uv_normalize::{DefaultExtras, DefaultGroups, PackageName};
use uv_preview::Preview;
use uv_python::{PythonDownloads, PythonPreference, PythonRequest};
use uv_requirements::is_pylock_toml;
use uv_resolver::{PylockToml, RequirementsTxtExport, cyclonedx_json};
use uv_scripts::Pep723Script;
use uv_settings::PythonInstallMirrors;
use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, WorkspaceCache};
use crate::commands::pip::loggers::DefaultResolveLogger;
use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::{LockMode, LockOperation};
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{
ProjectError, ProjectInterpreter, ScriptInterpreter, UniversalState, WorkspacePython,
default_dependency_groups, detect_conflicts,
};
use crate::commands::{ExitStatus, OutputWriter, diagnostics};
use crate::printer::Printer;
use crate::settings::{FrozenSource, LockCheck, ResolverSettings};
#[derive(Debug, Clone)]
#[expect(clippy::large_enum_variant)]
enum ExportTarget {
Script(Pep723Script),
Project(VirtualProject),
}
impl<'lock> From<&'lock ExportTarget> for LockTarget<'lock> {
fn from(value: &'lock ExportTarget) -> Self {
match value {
ExportTarget::Script(script) => Self::Script(script),
ExportTarget::Project(project) => Self::Workspace(project.workspace()),
}
}
}
#[expect(clippy::fn_params_excessive_bools)]
pub(crate) async fn export(
project_dir: &Path,
format: Option<ExportFormat>,
all_packages: bool,
package: Vec<PackageName>,
prune: Vec<PackageName>,
hashes: bool,
install_options: InstallOptions,
output_file: Option<PathBuf>,
extras: ExtrasSpecification,
groups: DependencyGroups,
editable: Option<EditableMode>,
lock_check: LockCheck,
frozen: Option<FrozenSource>,
include_annotations: bool,
include_header: bool,
script: Option<Pep723Script>,
python: Option<String>,
install_mirrors: PythonInstallMirrors,
settings: ResolverSettings,
client_builder: BaseClientBuilder<'_>,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
concurrency: Concurrency,
no_config: bool,
quiet: bool,
cache: &Cache,
printer: Printer,
preview: Preview,
) -> Result<ExitStatus> {
let workspace_cache = WorkspaceCache::default();
let target = if let Some(script) = script {
ExportTarget::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
};
ExportTarget::Project(project)
};
let default_groups = match &target {
ExportTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?,
ExportTarget::Script(_) => DefaultGroups::default(),
};
let default_extras = match &target {
ExportTarget::Project(_project) => DefaultExtras::default(),
ExportTarget::Script(_) => DefaultExtras::default(),
};
let groups = groups.with_defaults(default_groups);
let extras = extras.with_defaults(default_extras);
let interpreter = if frozen.is_some() {
None
} else {
Some(match &target {
ExportTarget::Script(script) => ScriptInterpreter::discover(
script.into(),
python.as_deref().map(PythonRequest::parse),
&client_builder,
python_preference,
python_downloads,
&install_mirrors,
no_config,
false,
Some(false),
cache,
printer,
preview,
)
.await?
.into_interpreter(),
ExportTarget::Project(project) => {
let workspace_python = WorkspacePython::from_request(
python.as_deref().map(PythonRequest::parse),
Some(project.workspace()),
&groups,
project_dir,
no_config,
)
.await?;
ProjectInterpreter::discover(
project.workspace(),
&groups,
workspace_python,
&client_builder,
python_preference,
python_downloads,
&install_mirrors,
false,
Some(false),
cache,
printer,
preview,
)
.await?
.into_interpreter()
}
})
};
let mode = if let Some(frozen_source) = frozen {
LockMode::Frozen(frozen_source.into())
} else if let LockCheck::Enabled(lock_check) = lock_check {
LockMode::Locked(interpreter.as_ref().unwrap(), lock_check)
} else if matches!(target, ExportTarget::Script(_))
&& !LockTarget::from(&target).lock_path().is_file()
{
LockMode::DryRun(interpreter.as_ref().unwrap())
} else {
LockMode::Write(interpreter.as_ref().unwrap())
};
let state = UniversalState::default();
let lock = match Box::pin(
LockOperation::new(
mode,
&settings,
&client_builder,
&state,
Box::new(DefaultResolveLogger),
&concurrency,
cache,
&workspace_cache,
printer,
preview,
)
.execute((&target).into()),
)
.await
{
Ok(result) => result.into_lock(),
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 target = match &target {
ExportTarget::Project(VirtualProject::Project(project)) => {
if all_packages {
InstallTarget::Workspace {
workspace: project.workspace(),
lock: &lock,
}
} else {
match package.as_slice() {
[] => InstallTarget::Project {
workspace: project.workspace(),
name: project.project_name(),
lock: &lock,
},
[name] => InstallTarget::Project {
workspace: project.workspace(),
name,
lock: &lock,
},
names => InstallTarget::Projects {
workspace: project.workspace(),
names,
lock: &lock,
},
}
}
}
ExportTarget::Project(VirtualProject::NonProject(workspace)) => {
if all_packages {
InstallTarget::NonProjectWorkspace {
workspace,
lock: &lock,
}
} else {
match package.as_slice() {
[] => InstallTarget::NonProjectWorkspace {
workspace,
lock: &lock,
},
[name] => InstallTarget::Project {
workspace,
name,
lock: &lock,
},
names => InstallTarget::Projects {
workspace,
names,
lock: &lock,
},
}
}
}
ExportTarget::Script(script) => InstallTarget::Script {
script,
lock: &lock,
},
};
target.validate_extras(&extras)?;
target.validate_groups(&groups)?;
if output_file
.as_deref()
.and_then(Path::file_name)
.is_some_and(|name| name.eq_ignore_ascii_case("pyproject.toml"))
{
return Err(anyhow!(
"`pyproject.toml` is not a supported output format for `{}` (supported formats: {})",
"uv export".green(),
ExportFormat::value_variants()
.iter()
.filter_map(clap::ValueEnum::to_possible_value)
.map(|value| value.get_name().to_string())
.join(", ")
));
}
let mut writer = OutputWriter::new(!quiet || output_file.is_none(), output_file.as_deref());
let format = format.unwrap_or_else(|| {
if output_file
.as_deref()
.and_then(Path::extension)
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
{
ExportFormat::RequirementsTxt
} else if output_file
.as_deref()
.and_then(Path::file_name)
.and_then(OsStr::to_str)
.is_some_and(is_pylock_toml)
{
ExportFormat::PylockToml
} else {
ExportFormat::RequirementsTxt
}
});
if !matches!(format, ExportFormat::CycloneDX1_5) {
detect_conflicts(&target, &extras, &groups)?;
}
if matches!(format, ExportFormat::PylockToml) {
if let Some(file_name) = output_file
.as_deref()
.and_then(Path::file_name)
.and_then(OsStr::to_str)
{
if !is_pylock_toml(file_name) {
return Err(anyhow!(
"Expected the output filename to start with `pylock.` and end with `.toml` (e.g., `pylock.toml`, `pylock.dev.toml`); `{file_name}` won't be recognized as a `pylock.toml` file in subsequent commands",
));
}
}
}
match format {
ExportFormat::RequirementsTxt => {
let export = RequirementsTxtExport::from_lock(
&target,
&prune,
&extras,
&groups,
include_annotations,
editable,
hashes,
&install_options,
)?;
if include_header {
writeln!(
writer,
"{}",
"# This file was autogenerated by uv via the following command:".green()
)?;
writeln!(writer, "{}", format!("# {}", cmd()).green())?;
}
write!(writer, "{export}")?;
}
ExportFormat::PylockToml => {
let export = PylockToml::from_lock(
&target,
&prune,
&extras,
&groups,
include_annotations,
editable,
&install_options,
)?;
if include_header {
writeln!(
writer,
"{}",
"# This file was autogenerated by uv via the following command:".green()
)?;
writeln!(writer, "{}", format!("# {}", cmd()).green())?;
}
write!(writer, "{}", export.to_toml()?)?;
}
ExportFormat::CycloneDX1_5 => {
let export = cyclonedx_json::from_lock(
&target,
&prune,
&extras,
&groups,
include_annotations,
&install_options,
preview,
all_packages,
)?;
export.output_as_json_v1_5(&mut writer)?;
}
}
writer.commit().await?;
Ok(ExitStatus::Success)
}
fn cmd() -> String {
let args = env::args_os()
.skip(1)
.map(|arg| arg.to_string_lossy().to_string())
.scan(None, move |skip_next, arg| {
if matches!(skip_next, Some(true)) {
*skip_next = None;
return Some(None);
}
if arg == "--upgrade" || arg == "-U" {
*skip_next = None;
return Some(None);
}
if arg == "--upgrade-package" || arg == "-P" {
*skip_next = Some(true);
return Some(None);
}
if arg.starts_with("--upgrade-package=") || arg.starts_with("-P") {
*skip_next = None;
return Some(None);
}
if arg == "--upgrade-group" {
*skip_next = Some(true);
return Some(None);
}
if arg.starts_with("--upgrade-group=") {
*skip_next = None;
return Some(None);
}
if arg == "--quiet" || arg == "-q" {
*skip_next = None;
return Some(None);
}
if arg == "--verbose" || arg == "-v" {
*skip_next = None;
return Some(None);
}
Some(Some(arg))
})
.flatten()
.join(" ");
format!("uv {args}")
}