diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index b617b648b598..a022144ff729 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2966,6 +2966,10 @@ pub struct ExportArgs { #[arg(long, overrides_with("hashes"))] pub no_hashes: bool, + /// Write the compiled requirements to the given `requirements.txt` file. + #[arg(long, short)] + pub output_file: Option, + /// Assert that the `uv.lock` will remain unchanged. /// /// Requires that the lockfile is up-to-date. If the lockfile is missing or diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 6e5ada1ad9dc..7b27537f40f9 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -1,8 +1,11 @@ -use std::time::Duration; -use std::{fmt::Display, fmt::Write, process::ExitCode}; - +use anstream::AutoStream; use anyhow::Context; use owo_colors::OwoColorize; +use std::borrow::Cow; +use std::io::stdout; +use std::path::Path; +use std::time::Duration; +use std::{fmt::Display, fmt::Write, process::ExitCode}; pub(crate) use build::build; pub(crate) use cache_clean::cache_clean; @@ -198,3 +201,54 @@ pub(crate) struct SharedState { /// The downloaded distributions. pub(crate) in_flight: InFlight, } + +/// A multicasting writer that writes to both the standard output and an output file, if present. +#[allow(clippy::disallowed_types)] +struct OutputWriter<'a> { + stdout: Option>, + output_file: Option<&'a Path>, + buffer: Vec, +} + +#[allow(clippy::disallowed_types)] +impl<'a> OutputWriter<'a> { + /// Create a new output writer. + fn new(include_stdout: bool, output_file: Option<&'a Path>) -> Self { + let stdout = include_stdout.then(|| AutoStream::::auto(stdout())); + Self { + stdout, + output_file, + buffer: Vec::new(), + } + } + + /// Write the given arguments to both standard output and the output buffer, if present. + fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> { + use std::io::Write; + + // Write to the buffer. + if self.output_file.is_some() { + self.buffer.write_fmt(args)?; + } + + // Write to standard output. + if let Some(stdout) = &mut self.stdout { + write!(stdout, "{args}")?; + } + + Ok(()) + } + + /// Commit the buffer to the output file. + async fn commit(self) -> std::io::Result<()> { + if let Some(output_file) = self.output_file { + // If the output file is an existing symlink, write to the destination instead. + let output_file = fs_err::read_link(output_file) + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed(output_file)); + let stream = anstream::adapter::strip_bytes(&self.buffer).into_vec(); + uv_fs::write_atomic(output_file, &stream).await?; + } + Ok(()) + } +} diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index de823097d13b..df0a1f7737e9 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -1,9 +1,7 @@ -use std::borrow::Cow; use std::env; -use std::io::stdout; use std::path::Path; -use anstream::{eprint, AutoStream}; +use anstream::eprint; use anyhow::{anyhow, Result}; use itertools::Itertools; use owo_colors::OwoColorize; @@ -43,7 +41,7 @@ use uv_warnings::warn_user; use crate::commands::pip::loggers::DefaultResolveLogger; use crate::commands::pip::{operations, resolution_environment}; -use crate::commands::ExitStatus; +use crate::commands::{ExitStatus, OutputWriter}; use crate::printer::Printer; /// Resolve a set of requirements into a set of pinned versions. @@ -621,54 +619,3 @@ fn cmd( .join(" "); format!("uv {args}") } - -/// A multicasting writer that writes to both the standard output and an output file, if present. -#[allow(clippy::disallowed_types)] -struct OutputWriter<'a> { - stdout: Option>, - output_file: Option<&'a Path>, - buffer: Vec, -} - -#[allow(clippy::disallowed_types)] -impl<'a> OutputWriter<'a> { - /// Create a new output writer. - fn new(include_stdout: bool, output_file: Option<&'a Path>) -> Self { - let stdout = include_stdout.then(|| AutoStream::::auto(stdout())); - Self { - stdout, - output_file, - buffer: Vec::new(), - } - } - - /// Write the given arguments to both standard output and the output buffer, if present. - fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> { - use std::io::Write; - - // Write to the buffer. - if self.output_file.is_some() { - self.buffer.write_fmt(args)?; - } - - // Write to standard output. - if let Some(stdout) = &mut self.stdout { - write!(stdout, "{args}")?; - } - - Ok(()) - } - - /// Commit the buffer to the output file. - async fn commit(self) -> std::io::Result<()> { - if let Some(output_file) = self.output_file { - // If the output file is an existing symlink, write to the destination instead. - let output_file = fs_err::read_link(output_file) - .map(Cow::Owned) - .unwrap_or(Cow::Borrowed(output_file)); - let stream = anstream::adapter::strip_bytes(&self.buffer).into_vec(); - uv_fs::write_atomic(output_file, &stream).await?; - } - Ok(()) - } -} diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 52eb0734dcd5..2d274d335f2e 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use owo_colors::OwoColorize; +use std::path::PathBuf; use uv_cache::Cache; use uv_client::Connectivity; @@ -13,7 +14,7 @@ use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace} use crate::commands::pip::loggers::DefaultResolveLogger; use crate::commands::project::lock::do_safe_lock; use crate::commands::project::{FoundInterpreter, ProjectError}; -use crate::commands::{pip, ExitStatus}; +use crate::commands::{pip, ExitStatus, OutputWriter}; use crate::printer::Printer; use crate::settings::ResolverSettings; @@ -23,6 +24,7 @@ pub(crate) async fn export( format: ExportFormat, package: Option, hashes: bool, + output_file: Option, extras: ExtrasSpecification, dev: bool, locked: bool, @@ -34,6 +36,7 @@ pub(crate) async fn export( connectivity: Connectivity, concurrency: Concurrency, native_tls: bool, + quiet: bool, cache: &Cache, printer: Printer, ) -> Result { @@ -110,6 +113,9 @@ pub(crate) async fn export( vec![] }; + // Write the resolved dependencies to the output channel. + let mut writer = OutputWriter::new(!quiet || output_file.is_none(), output_file.as_deref()); + // Generate the export. match format { ExportFormat::RequirementsTxt => { @@ -120,13 +126,16 @@ pub(crate) async fn export( &dev, hashes, )?; - anstream::println!( + writeln!( + writer, "{}", "# This file was autogenerated via `uv export`.".green() - ); - anstream::print!("{export}"); + )?; + write!(writer, "{export}")?; } } + writer.commit().await?; + Ok(ExitStatus::Success) } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f237b60d03e9..f645305293b2 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1323,6 +1323,7 @@ async fn run_project( args.format, args.package, args.hashes, + args.output_file, args.extras, args.dev, args.locked, @@ -1334,6 +1335,7 @@ async fn run_project( globals.connectivity, globals.concurrency, globals.native_tls, + globals.quiet, &cache, printer, ) diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 1e14742c9363..410040d9f06f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -957,6 +957,7 @@ pub(crate) struct ExportSettings { pub(crate) extras: ExtrasSpecification, pub(crate) dev: bool, pub(crate) hashes: bool, + pub(crate) output_file: Option, pub(crate) locked: bool, pub(crate) frozen: bool, pub(crate) python: Option, @@ -978,6 +979,7 @@ impl ExportSettings { no_dev, hashes, no_hashes, + output_file, locked, frozen, resolver, @@ -995,6 +997,7 @@ impl ExportSettings { ), dev: flag(dev, no_dev).unwrap_or(true), hashes: flag(hashes, no_hashes).unwrap_or(true), + output_file, locked, frozen, python, diff --git a/crates/uv/tests/export.rs b/crates/uv/tests/export.rs index a59a188deed5..64b78fd37adf 100644 --- a/crates/uv/tests/export.rs +++ b/crates/uv/tests/export.rs @@ -636,3 +636,62 @@ fn no_hashes() -> Result<()> { Ok(()) } + +#[test] +fn output_file() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--output-file").arg("requirements.txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated via `uv export`. + -e . + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + sniffio==1.3.1 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + let contents = fs_err::read_to_string(context.temp_dir.child("requirements.txt"))?; + insta::assert_snapshot!(contents, @r###" + # This file was autogenerated via `uv export`. + -e . + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + sniffio==1.3.1 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 + "###); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ee9debfc70f4..c653e41833b4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1873,6 +1873,8 @@ uv export [OPTIONS]

When disabled, uv will only use locally cached data and locally available files.

+
--output-file, -o output-file

Write the compiled requirements to the given requirements.txt file

+
--package package

Export the dependencies for a specific package in the workspace.

If the workspace member does not exist, uv will exit with an error.