From 42e3546fa00d9f062049dfef3e88e09831e98e02 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 17 Apr 2025 23:15:29 -0400 Subject: [PATCH] Infer output type --- crates/uv-cli/src/lib.rs | 7 +- crates/uv-requirements/src/sources.rs | 6 + crates/uv/src/commands/project/export.rs | 28 ++++- crates/uv/src/settings.rs | 2 +- crates/uv/tests/it/export.rs | 153 +++++++++++++++++++++++ docs/reference/cli.md | 3 +- 6 files changed, 192 insertions(+), 7 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 9d2798ceb318c..1aafe96ea7c00 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3764,8 +3764,11 @@ pub struct ExportArgs { /// The format to which `uv.lock` should be exported. /// /// Supports both `requirements.txt` and `pylock.toml` (PEP 751) output formats. - #[arg(long, value_enum, default_value_t = ExportFormat::default())] - pub format: ExportFormat, + /// + /// uv will infer the output format from the file extension of the output file, if + /// provided. Otherwise, defaults to `requirements.txt`. + #[arg(long, value_enum)] + pub format: Option, /// Export the entire workspace. /// diff --git a/crates/uv-requirements/src/sources.rs b/crates/uv-requirements/src/sources.rs index 0f5f07be77367..5af2a606b3eb0 100644 --- a/crates/uv-requirements/src/sources.rs +++ b/crates/uv-requirements/src/sources.rs @@ -228,3 +228,9 @@ impl std::fmt::Display for RequirementsSource { } } } + +/// Returns `true` if a file name matches the `pylock.toml` pattern defined in PEP 751. +#[allow(clippy::case_sensitive_file_extension_comparisons)] +pub fn is_pylock_toml(file_name: &str) -> bool { + file_name.starts_with("pylock.") && file_name.ends_with(".toml") +} diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 21ec89c0e372c..6de015e7960c4 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -1,10 +1,10 @@ use std::env; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use itertools::Itertools; use owo_colors::OwoColorize; -use std::path::{Path, PathBuf}; -use uv_settings::PythonInstallMirrors; use uv_cache::Cache; use uv_configuration::{ @@ -13,8 +13,10 @@ use uv_configuration::{ }; use uv_normalize::{DefaultGroups, PackageName}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; +use uv_requirements::is_pylock_toml; use uv_resolver::{PylockToml, RequirementsTxtExport}; use uv_scripts::{Pep723ItemRef, Pep723Script}; +use uv_settings::PythonInstallMirrors; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache}; use crate::commands::pip::loggers::DefaultResolveLogger; @@ -51,7 +53,7 @@ impl<'lock> From<&'lock ExportTarget> for LockTarget<'lock> { #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn export( project_dir: &Path, - format: ExportFormat, + format: Option, all_packages: bool, package: Option, prune: Vec, @@ -252,6 +254,26 @@ pub(crate) async fn export( // Write the resolved dependencies to the output channel. let mut writer = OutputWriter::new(!quiet || output_file.is_none(), output_file.as_deref()); + // Determine the output format. + 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 + } + }); + // Generate the export. match format { ExportFormat::RequirementsTxt => { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 387d80aff019d..b944b879f4e90 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1527,7 +1527,7 @@ impl TreeSettings { #[allow(clippy::struct_excessive_bools, dead_code)] #[derive(Debug, Clone)] pub(crate) struct ExportSettings { - pub(crate) format: ExportFormat, + pub(crate) format: Option, pub(crate) all_packages: bool, pub(crate) package: Option, pub(crate) prune: Vec, diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 7dba441fc2685..b48d3da4fdbf3 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -3923,3 +3923,156 @@ fn pep_751_sdist_url_subdirectory() -> Result<()> { Ok(()) } + +#[test] +fn pep_751_infer_output_format() -> 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("-o").arg("requirements.txt"), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] -o requirements.txt + -e . + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + # via project + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + # via anyio + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anyio + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.export().arg("-o").arg("pylock.toml"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] -o pylock.toml + lock-version = "1.0" + created-by = "uv" + requires-python = ">=3.12" + + [[packages]] + name = "anyio" + version = "3.7.0" + index = "https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", upload-time = 2023-05-27T11:12:46Z, size = 142737, hashes = { sha256 = "275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce" } } + wheels = [{ url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", upload-time = 2023-05-27T11:12:44Z, size = 80873, hashes = { sha256 = "eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0" } }] + + [[packages]] + name = "idna" + version = "3.6" + index = "https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", upload-time = 2023-11-25T15:40:54Z, size = 175426, hashes = { sha256 = "9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca" } } + wheels = [{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", upload-time = 2023-11-25T15:40:52Z, size = 61567, hashes = { sha256 = "c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" } }] + + [[packages]] + name = "project" + version = "0.1.0" + directory = { path = ".", editable = true } + + [[packages]] + name = "sniffio" + version = "1.3.1" + index = "https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", upload-time = 2024-02-25T23:20:04Z, size = 20372, hashes = { sha256 = "f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } } + wheels = [{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", upload-time = 2024-02-25T23:20:01Z, size = 10235, hashes = { sha256 = "2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" } }] + + ----- stderr ----- + Resolved 4 packages in [TIME] + "#); + + uv_snapshot!(context.filters(), context.export().arg("-o").arg("pylock.dev.toml"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] -o pylock.dev.toml + lock-version = "1.0" + created-by = "uv" + requires-python = ">=3.12" + + [[packages]] + name = "anyio" + version = "3.7.0" + index = "https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", upload-time = 2023-05-27T11:12:46Z, size = 142737, hashes = { sha256 = "275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce" } } + wheels = [{ url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", upload-time = 2023-05-27T11:12:44Z, size = 80873, hashes = { sha256 = "eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0" } }] + + [[packages]] + name = "idna" + version = "3.6" + index = "https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", upload-time = 2023-11-25T15:40:54Z, size = 175426, hashes = { sha256 = "9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca" } } + wheels = [{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", upload-time = 2023-11-25T15:40:52Z, size = 61567, hashes = { sha256 = "c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" } }] + + [[packages]] + name = "project" + version = "0.1.0" + directory = { path = ".", editable = true } + + [[packages]] + name = "sniffio" + version = "1.3.1" + index = "https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", upload-time = 2024-02-25T23:20:04Z, size = 20372, hashes = { sha256 = "f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } } + wheels = [{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", upload-time = 2024-02-25T23:20:01Z, size = 10235, hashes = { sha256 = "2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" } }] + + ----- stderr ----- + Resolved 4 packages in [TIME] + "#); + + // TODO(charlie): Error on `pyproject.toml`. Right now, it's treated as `requirements.txt`. + uv_snapshot!(context.filters(), context.export().arg("-o").arg("pyproject.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] -o pyproject.toml + -e . + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + # via project + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + # via anyio + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anyio + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 74077c609eafa..a35e152ac661c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2332,7 +2332,8 @@ uv export [OPTIONS]

Supports both requirements.txt and pylock.toml (PEP 751) output formats.

-

[default: requirements.txt]

+

uv will infer the output format from the file extension of the output file, if provided. Otherwise, defaults to requirements.txt.

+

Possible values: